Domain

ReviewEditor

Markdown editor extended with inline review threads, anchored comments, and collaborative annotation state.

import { ReviewEditor } from '@lostgradient/editor';
markdownreviewdomain-suite
01

Overview

Reference usage

Markdown editor extended with inline review threads, anchored comments, and collaborative annotation state.

Usage

svelte
<script lang="ts">
  import { ReviewEditor } from '@lostgradient/editor';
</script>

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:

svelte
<script lang="ts">
  import { onMount } from 'svelte';
  import { ReviewEditor, toRuntimeThreads } from '@lostgradient/editor';
  import type { ReviewState, Thread } from '@lostgradient/editor';

  let value = $state('');
  let threads = $state<Thread[]>([]);

  // `localStorage` does not exist while the component script runs on the
  // server, and the saved key may be missing on a first visit.
  onMount(() => {
    const stored = localStorage.getItem('review-state');
    if (stored === null) return;
    const saved: ReviewState = JSON.parse(stored);
    value = saved.content;
    threads = toRuntimeThreads(saved.threads);
  });
</script>

<ReviewEditor id="review" bind:value bind:threads currentUserId="steve" />

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:

ts
const orphaned = threads.filter((thread) => thread.anchor.status === 'orphaned');

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:

svelte
<script lang="ts">
  let editor: ReviewEditor;

  function restore(saved: ReviewState) {
    editor.setState(saved);
  }
</script>

<ReviewEditor bind:this={editor} id="review" currentUserId="steve" />

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.

svelte
<script lang="ts">
  import { ReviewEditor, createDiffReviewState } from '@lostgradient/editor';
  import type { DiffReviewState } from '@lostgradient/editor';

  let original = $state('# Plan\n\nShip login.');
  let value = $state('# Plan\n\nShip login and a dashboard.');

  const created = createDiffReviewState([
    {
      targetId: 'plan',
      kind: 'markdown',
      label: 'Document diff',
      original,
      current: value,
      normalizeInputs: true,
    },
  ]);
  if (!created.ok) throw new Error('Invalid initial diff-review target.');
  let diffReviewState = $state<DiffReviewState>(created.value);
</script>

<ReviewEditor
  id="plan"
  bind:original
  bind:value
  {diffReviewState}
  onDiffReviewStateChange={(next) => (diffReviewState = next)}
/>
  • Diff comments render in the same comment list as document threads, each row badged Diff · old / Diff · new / Diff · file to 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/value change (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/export exporters. They return {ok: false, error: {code: 'invalid-record', ...}} when diff review isn't enabled. The existing exportMarkdownSummary/exportUnifiedDiff/getFormData are unaffected and keep exporting only threads.

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.

Live preview

Basic review editor

Review editor view
1 1
Loading editor...

02

When to use

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

Examples

Basic review editor

Editable markdown with source, rendered, and diff views.

Scroll-to-thread and sidebar selection

A tall document with two off-screen anchored threads and an imperative scrollToThread control — open the comments sidebar from the toolbar to exercise ReviewEditor.scrollToThread and sidebar/anchor thread selection.

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.

04

Props

Props for review-editor
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.