Better Rich Text Buy licence

Suggestions

On this page
  1. Setup
  2. Toolbar
  3. Commands
  4. Editing rules
  5. Supported changes
  6. Roles and permissions
  7. Hooks
  8. Cards and panels
  9. Discussion
  10. Collaboration
  11. Stored data
  12. HTML
  13. Word
  14. On a server
  15. Limits

Suggestion mode (track changes) records edits as proposals instead of applying them. New text is shown in the author's colour, removed text stays in place struck through, and formatting, paragraph breaks, block types and list changes are marked until someone accepts or rejects them. Each decision is one undoable step.

Everything lives in the document, so suggestions save with it, sync through real-time collaboration without server changes, and can be resolved on a server without a browser.

The package is @better-rich-text/plugin-suggestions. The design is in ADR 0005.

Setup

TypeScript
import { createRichTextEditor, standardPreset } from '@better-rich-text/core';
import { createUsersPlugin } from '@better-rich-text/users';
import { SuggestionsPanel, suggestionsFeature, suggestionsPlugin } from '@better-rich-text/plugin-suggestions';

const instance = createRichTextEditor('#editor', {
  content: savedHtml,
  plugins: [
    suggestionsPlugin,
    createUsersPlugin({
      currentUserId: session.userId,
      fetchUsers: (ids) => api.users(ids), // [{ id, name, avatar?, description?, color? }]
    }),
  ],
  features: [...standardPreset.features, suggestionsFeature],
  toolbar: `${standardPreset.toolbar} | suggest acceptSuggestion rejectSuggestion previousSuggestion nextSuggestion reviewView`,
});

new SuggestionsPanel({ editor: instance.editor, view: instance.view, mount: document.querySelector('#review')! });

The users plugin is optional. With it, suggestions are authored as currentUserId and every card, hover card, panel entry and highlight shows the name, avatar, title and colour that fetchUsers returns. Records arrive asynchronously: the interface shows the stored name until then and repaints when the directory answers. Without it, the author is the editor's actorId and the name comes from the authorName option (default "You").

The demo is apps/playground/suggest.html (author switcher, seeded suggestions) and suggest.html?collab (two people at once).

Toolbar

ItemWhat it does
suggest Turns suggesting on or off for the current person. Pressed while suggesting.
acceptSuggestion Accepts the suggestion at the cursor, or every suggestion in the selection.
rejectSuggestion Rejects the suggestion at the cursor, or every suggestion in the selection.
previousSuggestion, nextSuggestion Selects the previous or next suggestion.
reviewView Show suggestions (markup), show final (all accepted) or show original (all rejected). Final and original are read-only renderings.

suggestionsFeature also shows a card when the pointer rests on a suggestion or the caret enters one, with Accept and Reject. Without EditorUi, call attachSuggestionsUi(editor, view).

Commands

CommandInputNotes
suggestion.setMode { enabled } Per person. Refused for suggest-only people.
suggestion.toggleMode none
suggestion.accept / suggestion.reject { id? } Without id, the suggestion at the cursor.
suggestion.acceptAll / suggestion.rejectAll { author? } Optionally only one author's suggestions.
suggestion.acceptInSelection / suggestion.rejectInSelection none Whole suggestions that touch the selection.
suggestion.next / suggestion.previous none Selection only, allowed when read-only.
suggestion.reply { id, body } Adds to the suggestion's discussion.
suggestion.deleteReply { id, replyId } Author only.

All have labels, descriptions and aliases, so they appear in the command palette and in editor.toolDefinitions() for AI tool use. Each decision is a single transaction: one undo step, one collaboration entry.

Editing rules

  • Typing next to your own pending insertion extends it. Typing straight after your own deletion turns it into a replacement. Adjacent edits group into one suggestion.

  • Deleting your own proposed text removes it outright. Deleting your own proposed paragraph break joins the paragraphs again.

  • Backspace and Delete over text that is already marked for removal move the caret over it.

  • Typing inside someone else's proposed text creates your own insertion inside theirs. Deleting it marks it for removal as your suggestion; their insertion stays pending. Accepting your deletion removes the text; rejecting their insertion removes it too.

  • Formatting text that someone else proposed records a formatting suggestion by you. Formatting your own proposed text applies directly.

  • A formatting change over text that already has a formatting suggestion keeps the original formatting as the "before" state.

  • Rejecting a block that contains other people's pending suggestions is refused (unresolved-dependency) until those are rejected, or rejected together (Reject all, Reject in selection).

Supported changes

ChangeRecorded as
Typing, paste, drop, IME, AI insertions Proposed text (suggestionInsert), new blocks as insertNode parts.
Deleting text, words, ranges Text kept and marked (suggestionDelete). Whole blocks kept as deleteNode parts.
Replacing a selection One suggestion with both.
Bold, italic, colour, any mark suggestionFormat with the previous marks.
Enter A new paragraph break (split part).
Backspace at a paragraph start, deleting across paragraphs A removed break (join part). Accepting joins the paragraphs.
Heading, paragraph type, alignment, list style, task checkbox attrs part with the previous type and attributes.
Lists and quotes on or off, indent, outdent wrap, unwrap, move and remove parts, reverted in reverse order.

Changes that cannot be represented are refused with capability-unavailable and a message, never half-tracked:

  • edits inside code blocks (they hold no marks);

  • replacing the whole document (setHTML, source editing, restoring a revision);

  • edits after which a normaliser would have to restructure the document (for example toggling a list across a mixed selection that needs repairing).

Turn off suggesting to make these changes.

Roles and permissions

  • Suggest-only. Actors listed in the suggestOnly option, or for whom the suggestions.enforced filter returns true, always suggest and cannot turn it off. They may reject (withdraw) their own suggestions but not accept. In collaboration, configure the same option on the authority; it also refuses collab.applyOperations and collab.replaceDocument from them, so exact operations cannot bypass tracking.

  • Who decides. The suggestions.canDecide filter receives the default answer and { suggestion, decision, actor }.

TypeScript
editor.hooks.addFilter('suggestions.canDecide', (allowed, { actor }) => allowed && reviewers.has(actor.actorId));

Hooks

HookKindArguments
suggestions.canDecide filter allowed, { suggestion, decision, actor }
suggestions.enforced filter enforced, actor
suggestions.track filter track, commandId, origin: return false to leave a command untracked
suggestions.attributes filter { id, author, name, time, meta }, { author, commandId }: adjust a new suggestion (for example add meta: { ticket })
suggestions.created action suggestion, transaction event
suggestions.accepted / suggestions.rejected action suggestions[], transaction event
suggestions.modeChanged action { actorId, enabled }
suggestions.replied action { suggestionId, reply }

Actions fire once, where the command runs: on the client in standalone use and on the authority in collaboration (not again when a client replays it).

Cards and panels

renderSuggestionCard(suggestion, context) builds one card and returns { element, update(suggestion), setActive(active), destroy() }. It needs only the editor, so a shared review sidebar can host suggestion cards next to comment threads:

TypeScript
import { listSuggestions, renderSuggestionCard, selectSuggestion } from '@better-rich-text/plugin-suggestions';

for (const suggestion of listSuggestions(editor.state)) {
  const card = renderSuggestionCard(suggestion, {
    editor,
    replies: true,
    onSelect: (s) => selectSuggestion(editor, view, s),
  });
  sidebar.append(card.element);
}

SuggestionsPanel is the built-in review panel: cards in reading order, Accept all and Reject all, and the active card following the caret. describeSuggestion(suggestion, state) gives the plain-language summary used by cards ("Replaced “seven” with “half past six”").

Discussion

Every suggestion can carry replies (suggestion.reply), stored in a suggestion-thread annotation keyed by the suggestion id and removed when the suggestion is decided. Comment threads also work on suggested text: a comment anchored to text that is later suggested for deletion stays attached, because the text is not removed until the deletion is accepted. Rejecting keeps the anchor; accepting the deletion orphans the comment like any other deletion.

Collaboration

Install suggestionsPlugin on clients and on the authority (with the same configuration). No server code is specific to suggestions:

  • the suggestion mode of each person is a document annotation, so the authority knows who is suggesting;

  • edits are rewritten into suggestion form while the command executes, on the client (prediction) and again on the authority (truth), with ids allocated from the command id, so both produce the same operations;

  • authorship comes from the authenticated actor service on the authority, never from the client;

  • accepting and rejecting are ordinary commands; a decision on a suggestion another person already decided fails with stale-target and the client shows the authority's result.

Stored data

Inline suggestions are marks on text leaves. All three carry the same attributes:

JSON
{ "type": "suggestionInsert", "attrs": { "id": "sa1b2.c3.4", "author": "maya", "name": "Maya Chen", "time": "2026-03-04T05:06:07.000Z", "meta": null } }
{ "type": "suggestionDelete", "attrs": { "id": "sa1b2.c3.4", "author": "maya", "name": "Maya Chen", "time": "2026-03-04T05:06:07.000Z", "meta": null } }
{ "type": "suggestionFormat", "attrs": { "id": "sa1b2.c3.9", "author": "sam", "name": "Sam Ortiz", "time": "2026-03-04T05:07:00.000Z", "meta": null,
  "before": [{ "type": "italic", "attrs": {} }] } }

author is the user id; name is a snapshot used only as a fallback when no users directory is installed or it has not answered. meta is host data from the suggestions.attributes filter. A leaf can carry an insertion by one person and a deletion by another.

Structural changes are a suggestion annotation with the same id:

JSON
{
  "id": "suggestion:sa1b2.c3.4",
  "kind": "suggestion",
  "data": {
    "id": "sa1b2.c3.4", "author": "sam", "name": "Sam Ortiz", "time": "2026-03-04T05:07:00.000Z",
    "parts": [
      { "type": "split", "nodeId": "p-12", "newId": "sa1b2.c3.2", "seq": 7 },
      { "type": "attrs", "nodeId": "h-1", "beforeType": "heading", "beforeAttrs": { "level": 2 }, "seq": 8 }
    ]
  }
}

Part types: insertNode {nodeId}, deleteNode {nodeId}, split {nodeId, newId}, join {nodeId, withId, textblocks}, attrs {nodeId, beforeType, beforeAttrs}, wrap {wrapperId}, unwrap {parentId, wrapper, firstId, lastId}, move {nodeId, fromParentId, afterId}, remove {node, parentId, afterId}. seq orders parts across all suggestions; decisions apply parts in that order and revert them newest first.

Other annotations: suggestion-mode:<userId> ({ actorId, enabled: true }, present while that person suggests) and suggestion-thread:<suggestionId> ({ suggestionId, replies: [{ id, authorId, authorName, body, createdAt, deleted? }] }).

A suggestion is everything sharing one id; listSuggestions(state) derives the list, in document order, with { id, author, authorName, time, kinds, ranges, parts, inserted, deleted, meta }.

HTML

HTML
<p>The walk opens at <del data-suggestion="delete" data-suggestion-id="sa1b2.c3.4" data-author="maya" data-author-name="Maya Chen" data-time="2026-03-04T05:06:07.000Z">seven</del><ins data-suggestion="insert" data-suggestion-id="sa1b2.c3.4" data-author="maya" data-author-name="Maya Chen" data-time="2026-03-04T05:06:07.000Z">half past six</ins>.</p>
<p>Bring <span data-suggestion="format" data-suggestion-id="sa1b2.c3.9" data-author="sam" data-author-name="Sam Ortiz" data-time="2026-03-04T05:07:00.000Z" data-before="[]"><strong>sturdy shoes</strong></span>.</p>

data-meta holds meta as JSON when set. Parsing this HTML through the editor's codec restores the marks; plain <ins> and <del> without data-suggestion still mean underline and strikethrough. Structural parts are not in HTML: keep the JSON document for a full round trip, or export a resolved view.

Word

With the documents plugin, Export Word writes pending suggestions as Word tracked changes: insertions and deletions with their author and date, format changes with the previous formatting, proposed paragraph breaks and joins as inserted and deleted paragraph marks, and paragraph type changes. Importing a Word file turns its tracked changes into pending suggestions, or accepts or rejects them all (docx.import.trackedChanges, or the choice in the import preview). docx: { trackedChanges: 'accept' } exports the document with every suggestion applied instead. See Documents for details and limits.

On a server

A server stores and relays suggestions like any other document data; it does nothing special to accept or reject them during editing. To resolve or export them without a browser, use the headless helpers with the plugins the document was written with:

JavaScript
import { readFile } from 'node:fs/promises';
import { marksPlugin } from '@better-rich-text/plugin-marks';
import { blocksPlugin } from '@better-rich-text/plugin-blocks';
import { listsPlugin } from '@better-rich-text/plugin-lists';
import { linkPlugin } from '@better-rich-text/plugin-link';
import { acceptAllSuggestions, rejectAllSuggestions, suggestionsOf, suggestionsToHtml } from '@better-rich-text/plugin-suggestions';

const document = JSON.parse(await readFile('report.json', 'utf8'));
// The plugins the document was written with (the suggestions plugin is added for you).
const plugins = [marksPlugin, blocksPlugin, listsPlugin, linkPlugin];

const pending = suggestionsOf(document, { plugins });          // [{ id, author, kinds, inserted, deleted, ... }]
const published = acceptAllSuggestions(document, { plugins });  // envelope with every suggestion accepted
const original = rejectAllSuggestions(document, { plugins });    // envelope with every suggestion rejected
const html = suggestionsToHtml(document, { plugins, view: 'final' }); // 'markup' | 'final' | 'original'

Pass the same plugins your editor preset uses; the helpers need no DOM. To decide individual suggestions on a server, run suggestion.accept or suggestion.reject with { id } in a headless editor (createEditor from @better-rich-text/engine), or submit them through the collaboration authority so connected clients receive the result.

Limits

  • Accepting or rejecting in a selection decides whole suggestions that touch it, not parts of them.

  • Structural rejects (lists, quotes, moves) need the surrounding structure to be unchanged since; otherwise they fail with conflict and can still be accepted.

  • Final and original views are read-only renderings; switch back to edit.

  • Structural parts are not carried in HTML.

  • Suggested wraps, unwraps and moves are written to Word as proposed; Word has no tracked-change form for them.

  • When a block a suggestion changed is merged into another by a direct edit, its type change is dropped.