Read-Only Documents
useSlashEditor({ editable: false }) for a viewer — what stays interactive, what goes inert by design, and the aiBlock caveat.
A read-only document is a viewer, not a degraded editor. Two decisions shape it: how long it stays read-only, and which interactive seams you deliberately leave on.
Permanent viewer vs. runtime toggle
Pass editable: false when the document is only ever read — a published page, a shared preview:
import { useSlashEditor } from "@slash-editor/react";
const editor = useSlashEditor({ content, editable: false });For a runtime toggle — preview mode, a permissions flip — flip the running editor with
editor.setEditable(false) instead, and editor.setEditable(true) turns editing back on.
Keeping a viewer interactive
Left alone, a read-only editor is inert everywhere, and that is mostly what you want. Two block kit options keep the parts a reader actually touches alive.
The kit defaults link to { openOnClick: false, enableClickSelection: true } so that clicking
a link while editing selects it — feeding the link editor — instead of navigating away mid-edit.
A viewer wants the links back. And taskItem's onReadOnlyChecked is what makes a checkbox
clickable in a read-only document: without it Tiptap ignores the click and snaps the checkbox
back.
import { useSlashEditor } from "@slash-editor/react";
const editor = useSlashEditor({
content,
editable: false,
blockKit: {
link: { openOnClick: true },
taskItem: {
onReadOnlyChecked: (item, checked) => {
saveCheckState(item, checked); // your store — nothing is persisted for you
return true;
},
},
},
});onReadOnlyChecked receives the task item's node and the requested checked state. Return true
and the checkbox keeps its new look; return false and it snaps back. It is a notification, not
a document change — the read-only task item dispatches nothing — so persisting the check is the
host's job. The demo above is wired with exactly these two options: try the link and the
checkboxes. The playground carries the same switch in its header — flip
Read-only to watch the slash menu, mentions, and formatting stand down mid-session
(setEditable toggles the running editor rather than rebuilding it).
Node-view surfaces that are read-only by nature keep working too: a Mermaid diagram still renders
its preview, and an embed is a live iframe or a bookmark link out.
Inert by design
Everything else stands down when editor.isEditable is false, at the layer that owns it:
- Slash menu and mentions. Tiptap's
Suggestionplugin only matches triggers while the editor is editable —/and@are just characters in a viewer. - Bubble toolbar.
computeStateinbubble-toolbar.tsrefuses to open unless the editor is editable, and there is no editable text selection to anchor to anyway. - Link editor.
canOpenLinkEditorinlink-editor.tsreturnsfalseunless the editor is editable, so clicks never surface editing chrome — withopenOnClick: truethey navigate instead. - Empty-block placeholders. The decoration plugin in
placeholder.tsdraws nothing unless the editor is editable — a typing hint has no business in a viewer. - Toggle chevron.
openis document state — that is why a saved toggle reopens where its author left it — so flipping one is an edit.toggle.ts's click handler returns early unless the editor is editable and nothing reaches the document; the chevron still opens and closes the body for reading, but the flip is view-local. - Mermaid click-to-edit. The click handler in
mermaid-node-view.tsxbails unless the editor is editable — clicking a diagram in a viewer never opens its source. - Gutter block handle. Every affordance in it is a mutation — insert below, drag to reorder,
duplicate, delete, turn into — so
BlockHandlerenders nothing at all while the editor is read-only. A viewer can mount it unconditionally, and a host that flipssetEditableat runtime (the playground's own switch) gets that for free.
The aiBlock caveat
One node opts out of the pattern above. A document saved mid-AI-stream contains a transient
aiBlock node, and the registry's AiBlockNodeView never checks editor.isEditable — its
Replace/Try again/Discard buttons run document commands in any editor that has the node. A viewer
that may open such documents keeps ai: { adapter, node: false } registered and renders the node
through a NodeView of its own. node: false is the kit's seam for skipping its built-in node,
so the variant you register via extend is the only one in the schema:
import { AiBlock } from "@slash-editor/core";
import { useSlashEditor } from "@slash-editor/react";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { ReadOnlyAiBlockNodeView } from "@/components/nodes/read-only-ai-block-node-view";
const editor = useSlashEditor({
editable: false,
blockKit: {
ai: { adapter: yourStreamAdapter, node: false },
extend: [
AiBlock.extend({
addNodeView: () => ReactNodeViewRenderer(ReadOnlyAiBlockNodeView),
}),
],
},
});If ai is omitted entirely there is no aiBlock in the schema at all: loading a document that
contains one degrades rather than rendering it — Tiptap logs [tiptap warn]: Invalid content.
and drops what the schema cannot represent — acceptable for a viewer that never opens
half-streamed saves, but none of the stream state is left to show.
A table of contents mounted in the same viewer follows the same rule as the demo above: with no caret to follow, its active row comes from the scroll position.