Skip to Content
Editor Modes

Editor Modes

Every editor instance has a mode that controls whether the document can be mutated:

ModeTypingProgrammatic editsExportsImportsStatus
"edit" (default)stable
"review"✅ — every edit becomes a tracked suggestion✅ — samestable, 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:

  1. 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".)
  2. 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.

Last updated on