Domain

ReviewEditor

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

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

Overview

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

Usage

svelte
<script lang="ts">
  import { ReviewEditor } from '@lostgradient/editor/review-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/review-editor';
  import type { ReviewState, Thread } from '@lostgradient/editor/review-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.

Live preview
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
NameTypeDefaultDescription
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.