Overview
Reference usage
Markdown editor extended with inline review threads, anchored comments, and collaborative annotation state.
Usage
Guidance
Use When
- Building a document review experience that needs both a Markdown editor and anchored comment threads in one bundled surface.
- Threading reviewer commentary against specific selections inside a long-form document.
Avoid When
- Plain authoring with no review threads — markdown-editor is the lighter primitive.
- Reviewing diffs between two documents rather than annotating one — use diff-viewer instead.
Persisting and restoring
getState() returns a serializable ReviewState. Its threads are PersistedThread[], which deliberately drop the ProseMirror from/to positions — those only mean something against a live document, and a document can move on between save and restore.
To restore, bind value and threads from the same saved state and run the threads through toRuntimeThreads:
toRuntimeThreads seeds from/to with 0. That pair is an unplaced sentinel rather than a position: a collapsed range paints no highlight, and the anchor plugin locates each thread by its quote against the live document and writes the real positions back through the binding shortly after mount.
Restoring against different content is survivable. Because every restored thread goes through re-anchoring, a thread whose quote is no longer in the document comes back orphaned rather than placed: it is kept in threads, paints no highlight, shows in the sidebar as missing its text, and re-anchors on a later pass if the text returns. onThreadDelete does not fire, and no cleanup is owed — removing an orphaned thread is your decision, made with deleteThread.
The same is true while editing. Deleting anchored text orphans its thread instead of destroying it, because a deletion and the first half of a cut-and-paste are indistinguishable at the moment the text disappears, and re-anchoring is debounced 300ms — faster than anyone cutting a paragraph and pasting it back. Check anchor.status to tell the two states apart:
Comment exports carry the same signal: an orphaned thread serializes with status: 'orphaned', and its stale coordinates move to lastKnownSelection (omitted entirely when no genuine historical offset exists) so nothing reads as a current position.
The imperative alternative is setState(saved), which sets the content and re-anchors in one call:
Do not hand-compute anchor positions. If you seed threads directly, anchor.from/to are ProseMirror positions against the full document — including the front matter's character length when the content has any — while anchor.lastKnownOffset is a doc.textBetween() text offset. Neither is an index into the Markdown string. A newly seeded anchor that does not match the document is reported in dev and re-anchored by quote. The 0/0 sentinel created by toRuntimeThreads is intentionally exempt from that warning and re-anchors normally. Restore through toRuntimeThreads or setState, and let the component compute the positions.
Diff comments (opt-in, COR-512 / DR-7)
Supplying diffReviewState enables a second, independent comment domain inside the same instance: diff comments on the embedded diff tab, alongside the existing ProseMirror-anchored threads. It round-trips separately from ReviewState/threads — there is no migration between the two, and they never share an ID space.
- Diff comments render in the same comment list as document threads, each row badged
Diff · old/Diff · new/Diff · fileto distinguish the anchor domain. Selecting a current diff comment opens the diff tab and focuses its side; an outdated or removed one focuses its own captured detail instead. - Every committed
original/valuechange (typing, a front-matter edit,setMarkdown, "Revert all") immediately latches every diff comment on that document outdated — it is never dropped, only marked stale. - An invalid
diffReviewState(malformed shape, an unsupported version) shows a visible error and keeps the last state that validated; document editing is never affected. Removing the prop stops the diff tab's controls without erasing your own state object — supply it again to resume. - Cancelling the inline composer never deletes a nonempty draft. It stays reachable from a persistent "Unsaved drafts" panel (rendered regardless of which tab is open) with Open/Save/Discard actions — every export scope is blocked (
drafts-pending) until each nonempty draft is saved or explicitly discarded. exportAggregateReviewMarkdown()/exportAggregateReviewJson()(also on the public component instance) export every saved diff comment plus every retained document thread/reply, each exactly once, through the real@lostgradient/editor/exportexporters. They return{ok: false, error: {code: 'invalid-record', ...}}when diff review isn't enabled. The existingexportMarkdownSummary/exportUnifiedDiff/getFormDataare unaffected and keep exporting onlythreads.
See docs/decisions/diff-review-contract.md (this package's documentation/diff-review-contract.md) for the full data/export contract, and DiffReview/DiffReviewComments for the equivalent standalone (non-ReviewEditor) composition.
When to use
- Building a document review experience that needs both a Markdown editor and anchored comment threads in one bundled surface.
- Threading reviewer commentary against specific selections inside a long-form document.
- Plain authoring with no review threads — markdown-editor is the lighter primitive.
- Reviewing diffs between two documents rather than annotating one — use diff-viewer instead.
Examples
Basic review editor
Editable markdown with source, rendered, and diff views.
Review editor with comments
Anchored text comments and document-level feedback.
Review editor with comments (readonly)
Same anchored thread as "Review editor with comments", but mode="readonly" — for cinder#1304's comment-navigation-chord finding: in readonly mode, ProseMirror sets contenteditable="false" on the editor DOM, which removes its native focusability, so a real Tab press lands on the outer .markdown-editor.surface host instead. See comment-anchor-a11y.playwright.ts.
Props
Name | Type | Default | Description |
|---|---|---|---|
original bindable | text | '' | Original/baseline content for diff comparison. |
value bindable | text | '' | Current markdown content (two-way bindable). |
threads bindable | Thread[] | [] | Comment threads (two-way bindable).
Anchor positions use two different coordinate spaces, and neither is a raw
Markdown string index: anchor.from and anchor.to are ProseMirror
document positions, while anchor.lastKnownOffset and
anchor.originalPosition.offset are doc.textBetween() text offsets. All
four are expressed against the full document — when the content carries
YAML front matter, the component subtracts the front matter's character
length before handing anchors to the editor.
Positions are verified against the document. A newly seeded anchor whose
range does not match its quote is reported in dev and re-anchored by
quote. The intentional 0/0 sentinel created by
toRuntimeThreads(state.threads) is exempt from the warning and re-anchors
normally. Restore persisted state with that helper (or call setState)
rather than hand-computing positions. See shared/anchor-types.ts for the
field-by-field contract. |