Editor Modes
Every editor instance has a mode that controls whether the document can be mutated:
| Mode | Typing | Programmatic edits | Exports | Imports | Status |
|---|---|---|---|---|---|
"edit" (default) | ✅ | ✅ | ✅ | ✅ | stable |
"review" | ✅ — every edit becomes a tracked suggestion | ✅ — same | ✅ | ❌ | stable, requires the track-changes plugin |
"view" | ❌ | ❌ (dropped at dispatch) | ✅ | ❌ | stable |
Setting the mode
Pass mode to StesuraEditor:
<StesuraEditor
state={editorState}
dispatchTransaction={dispatch}
schema={schema}
pluginFactory={pluginFactory}
mode="view"
/>editable={false} is the deprecated equivalent of mode="view"; mode wins
when both are set.
To change the mode at runtime, for example from your own toggle, use
useSetEditorMode. The built-in status bar already has one: it offers
Editing and Viewing, plus Suggesting when the track-changes plugin is mounted.
import { useSetEditorMode } from "@stesura/editor-react";
const ModeToggle = () => {
const setEditorMode = useSetEditorMode();
return <button onClick={() => setEditorMode("view")}>Lock</button>;
};The setter writes the editor’s mode store and dispatches a meta-only
transaction so contenteditable updates at once. Switching to "review" also
turns on suggestion visibility. useSetEditorMode(editorId?) targets the given
editor, else the surrounding editor, else the current root editor.
A runtime change sticks until the mode prop itself changes; re-rendering with
the same prop does not undo it.
To read the mode, use useEditorStoreValue("mode") (same editor resolution) or
useBoundEditorMode() (the editor the toolbar is bound to). Both re-render only
when the mode changes.
Sub-editors (headers, footers, footnotes, endnotes) have no mode of their own: they share the root editor’s.
What review mode enforces
Review mode means every local edit is recorded as a suggestion, whatever the
track-changes toggle says. It is enforced at the dispatch layer, not by the
plugin’s own enabled flag: the editor passes forceTrackChanges to the
transaction modifier (trackChangesTransactionModifier, which you pass to both
the mount hook and StesuraEditor) for as long as the mode is set, so typing, paste, drop,
formatting commands, AI edits and sub-editor edits (headers, footers, notes) all
produce tracked output through the one code path.
It requires the track-changes plugin. Without one, a “review” editor would
be ordinary editing under a reviewer’s label, and the mistake would only surface
as a document full of unflagged edits — so it throws instead, both from the
mode prop and from useSetEditorMode:
import { StesuraEditor, useLocalEditor } from "@stesura/editor-react";
import { trackChangesPlugin, trackChangesTransactionModifier } from "@stesura/track-changes";
const { editorState, dispatch, pluginFactory, schema } = useLocalEditor({
// Mount the plugin, or StesuraEditor throws on render.
extraPlugins: () => [trackChangesPlugin()],
transactionModifier: trackChangesTransactionModifier,
});
<StesuraEditor
state={editorState}
dispatchTransaction={dispatch}
schema={schema}
pluginFactory={pluginFactory}
transactionModifier={trackChangesTransactionModifier}
mode="review"
/>While the mode is set, turning tracking off is refused. toggleTrackChanges,
disableTrackChanges and resolveAllAndStopTrackChanges all report unavailable,
which is what greys out their toolbar items; the “Track changes” button renders
locked-on with a tooltip saying why. Plain “Accept all” / “Reject all” still
work — resolving existing suggestions is not the same as stopping tracking. Who
may accept or reject in review mode is not yet a distinct permission.
If the track-changes rewrite itself fails (an internal error), the edit is
dropped rather than applied untracked, and the failure is reported through
setTrackChangesErrorHandler. In "edit" mode the same failure degrades the
other way — the edit lands untracked — because there the user’s typing matters
more than the suggestion.
Remote collaboration changes are not re-tracked: they replay steps that were already tracked at their origin. Merging and splitting cells, and deleting a row that crosses a vertical merge, are the one deliberate exception: they cannot be expressed as suggestions (Word skips them too) and land untracked in every mode. Other row and column deletions are tracked.
What view mode enforces
View mode is enforced at two layers:
- UI gating: toolbar buttons, context-menu items, floating menus, the
ruler and the DOCX import button disable themselves. (DOCX import is also
refused in
"review".) - A dispatch gate — every local transaction that would change the
document is dropped before it applies, whatever dispatched it (commands,
paste, drop, a plugin calling
view.dispatch). A dropped transaction logs a console warning. The gate runs before track changes, so a refused edit is never recorded as a suggestion.
The gate lets through:
- Selection-only and metadata transactions — selecting, copying, search, and UI bookkeeping all keep working.
- Remote collaboration changes — a read-only client keeps receiving fresh content and never diverges from the server. Undo/redo counts as a local edit and is blocked.
- Transactions tagged as system edits (see below).
- Transactions appended by plugins (
appendTransaction). The gate only sees the root transaction, so maintenance such as TOC rebuilds and normalization keeps working.
What still works in view mode: text/cell/node selection with a persistent
highlight (no blinking caret — the document is not contenteditable), copy
(shortcut and context menu, full editor fidelity), search, exports (DOCX,
JSON, PDF). Dragging content out of the document is blocked.
Custom toolbar commands
In view mode the toolbar helpers (useRunTbCommand, useCanRunTb,
useCanRunTbCommand) refuse every command by default. For a command that only
reads, such as opening a read-only panel, mark it once at module scope:
import { allowInViewMode } from "@stesura/editor-react";
const openMyPanel = allowInViewMode((state, dispatch) => {
// must not change the document
return true;
});The mark is on the command reference. A marked command that changes the document is still dropped by the dispatch gate.
Deliberate programmatic edits
ProseMirror commands dispatch through whatever dispatch function you hand
them, so the escape hatch is explicit and per-call:
import { privilegedDispatch } from "@stesura/core/guards";
// Blocked in view mode:
myCommand(view.state, view.dispatch);
// Runs in view mode — the transaction is tagged as a system edit:
myCommand(view.state, privilegedDispatch(view));Equivalently, tag a hand-built transaction with
tr.setMeta("systemEdit", true). Reserve this for maintenance-style
operations (healing, migrations) — not for sneaking user edits past the gate.
Reading the mode in core
The mode (EditorMode from @stesura/core/types) lives in the editor’s
stores, not in a plugin, so framework-free code reads it from a state:
import { getEditorStores } from "@stesura/core/stores";
const mode = getEditorStores(state)?.mode.get(); // "edit" | "review" | "view"A sub-editor’s lookup falls through to its parent editor’s stores, which is how
headers, footers and notes share the main document’s mode. For a custom dispatch
pipeline, @stesura/core/guards exports the gate predicates:
isBlockedByMode(state, tr) for the view-mode gate and
isForcedTrackChanges(state, tr) for review mode. Both are read at dispatch
time, so a mode change applies to the next transaction.
Scope
Mode is a client-side control. In a collaborative setup the server’s
authorize callback gates every request. It gets the request and
{ docId, path } but has no built-in reader/writer role, so unless your gate
refuses write routes (e.g. POST to commits) for read-only users, a modified
client can still push edits.
Treat view mode as UX and integrity for well-behaved clients, and enforce hard
permissions server-side.