Suggestions
On this page
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
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
| Item | What 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
| Command | Input | Notes |
|---|---|---|
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
| Change | Recorded 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
suggestOnlyoption, or for whom thesuggestions.enforcedfilter 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 refusescollab.applyOperationsandcollab.replaceDocumentfrom them, so exact operations cannot bypass tracking.Who decides. The
suggestions.canDecidefilter receives the default answer and{ suggestion, decision, actor }.
editor.hooks.addFilter('suggestions.canDecide', (allowed, { actor }) => allowed && reviewers.has(actor.actorId));Hooks
| Hook | Kind | Arguments |
|---|---|---|
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:
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
actorservice 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-targetand the client shows the authority's result.
Stored data
Inline suggestions are marks on text leaves. All three carry the same attributes:
{ "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:
{
"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
<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:
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
conflictand 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.