Rich-text editing
Rinch ships a built-in rich-text editor — a ProseMirror-style, model-first editor
with schema-enforced structure, inline marks, lists, tables, and exact undo/redo.
You add it with the Editor {} component and drive it through an EditorHandle.
This page is the practical “how do I add an editor” guide. For the deeper model — the document tree, steps and transactions, the schema, commands, and the view seam — see the Rich-Text Editor guide.
Desktop today. The editor view is a desktop feature (gated behind the
desktopcargo feature). The editor core is renderer-agnostic; a web view (over the browser’s native contentEditable) is a planned follow-up. There is nocontenteditableHTML attribute and no DOM-level editing API to wire up — theEditorcomponent is the whole surface.
Quick start
Create a handle with create_editor(), mount it with Editor {}, and dispatch
named commands from toolbar buttons. Everything you need is in the prelude:
#![allow(unused)]
fn main() {
use rinch::prelude::*;
#[component]
fn my_editor() -> NodeHandle {
let editor = create_editor();
// One cheap handle clone per closure that needs it (EditorHandle is an Rc).
let ed_bold = editor.clone();
let ed_italic = editor.clone();
let ed_h1 = editor.clone();
rsx! {
div {
// Toolbar — each button runs a named command.
div {
button { onclick: move || { ed_bold.command("toggleBold"); }, "Bold" }
button { onclick: move || { ed_italic.command("toggleItalic"); }, "Italic" }
button { onclick: move || { ed_h1.command("setHeading1"); }, "H1" }
}
// The editing surface — mounts the handle and renders its content.
Editor {
editor: editor.clone(),
content: "<h1>Hello</h1><p>Type here…</p>",
}
}
}
}
}
The Editor component renders the document, the caret, and the selection straight
from the editor’s state. It ships its own default light/dark stylesheet, so the
content looks right out of the box — you don’t hand-roll editor CSS.
The Editor component
| Prop | Type | Purpose |
|---|---|---|
editor | Option<EditorHandle> | A handle from create_editor(). Omit it and the component creates its own self-contained editor. |
content | String | Initial content as schema-whitelisted HTML, parsed into the document once on mount. |
Like every rinch component, Editor {} also accepts the universal style: and
class: props, applied to its host element.
#![allow(unused)]
fn main() {
// Self-contained: no handle needed if you don't drive it programmatically.
rsx! { Editor { content: "<p>Just some editable text.</p>" } }
}
The EditorHandle
create_editor() returns an EditorHandle — a cheap, cloneable (Rc) handle to a
single editor. Clone one per closure that captures it; all clones share the same
editor. It works before the editor is mounted or focused: state edits mutate the
owned state and render when the view attaches.
Driving the editor: command
handle.command(name) dispatches a built-in command by name and returns whether it
applied. This is the single entry point for toolbar buttons and menu items:
#![allow(unused)]
fn main() {
let ed = editor.clone();
button { onclick: move || { ed.command("toggleBold"); }, "Bold" }
}
Command names are case-sensitive. The full catalogue:
| Category | Commands |
|---|---|
| Inline marks | toggleBold, toggleItalic, toggleUnderline, toggleStrike, toggleCode, toggleHighlight, toggleSubscript, toggleSuperscript |
| Block types | setParagraph, setHeading1…setHeading6, setCodeBlock |
| Alignment | setTextAlignLeft, setTextAlignCenter, setTextAlignRight, setTextAlignJustify |
| Containers | toggleBulletList, toggleOrderedList, toggleTaskList, wrapInBlockquote |
| Lists | sinkListItem (indent), liftListItem (outdent) |
| Inserts | insertHorizontalRule, insertHardBreak, insertTable |
| Tables | addRowAfter, addRowBefore, addColumnAfter, addColumnBefore, deleteRow, deleteColumn, deleteTable, mergeCells, splitCell |
| Links | removeLink |
| History | undo, redo |
Links need a destination, so applying a link is a builder rather than a bare named command. Use
handle.toggle_link(href)to add (or, over an existing link, remove) a link,handle.command("removeLink")to clear one unconditionally, andhandle.active_link_href()to read the current link’s target for an edit dialog.
Alignment applies to the textblocks (
paragraph/heading) overlapping the selection, including ones nested in lists, blockquotes, and table cells. Re-applying the alignment a block already has is a no-op, so a toolbar button bound to the current alignment stays inert.setParagraph(“reset to normal text”) clears alignment back toleftalong with the rest of the formatting.
Querying state
Toolbar “active” states and enablement read the editor state, never the DOM:
#![allow(unused)]
fn main() {
handle.is_mark_active("bold") // -> bool: is bold active at the selection?
handle.current_block_type() // -> Option<String>: e.g. Some("heading")
handle.in_node_type("bullet_list") // -> bool: is the cursor inside a bullet list?
handle.can_run("liftListItem") // -> bool: would this command apply right now?
}
A reactive toolbar button can read these inside a {|| ... } closure so it
re-renders when the selection moves:
#![allow(unused)]
fn main() {
let ed = editor.clone();
Button {
variant: {|| if ed.is_mark_active("bold") { "filled" } else { "subtle" }},
onclick: move || { ed.command("toggleBold"); },
"B"
}
}
Setting content
Pass HTML through the content: prop, or load it imperatively:
#![allow(unused)]
fn main() {
let editor = create_editor();
// Before or after mount — both work; the view renders the result either way.
editor.load_html("<h1>Loaded</h1><p>Programmatically set content.</p>");
rsx! { Editor { editor: editor.clone() } }
}
| Method | Purpose |
|---|---|
load_html(&str) -> bool | Parse schema-whitelisted HTML and replace the document. Returns false if it doesn’t parse into valid content. |
doc() -> Node | The current document (the save shape; serialize it under the serde feature). |
insert_image(src, alt) | Insert an image node (e.g. a data: URL), replacing the selection. |
toggle_link(href) -> bool | Add a link mark with href across the selection, or remove it if the selection is already linked. No-op (returns false) for a collapsed cursor. |
active_link_href() -> Option<String> | The href of the link at the selection head, for pre-filling an “edit link” dialog. None when not inside a link. |
replace_selection_with_html(&str) | Replace the selection with parsed HTML (the rich-paste path). |
selection_clipboard() | The current selection serialized as (html, plain_text) for the clipboard. |
HTML is schema-whitelisted on load: known block tags become nodes and known
inline tags become marks; unknown tags and attributes (<script>, inline event
handlers, …) are dropped at parse time. The document can only ever hold structure
the schema allows.
Dark mode
The editor’s built-in stylesheet has light and dark color schemes; toggle between
them with set_dark_mode:
#![allow(unused)]
fn main() {
let dark = Signal::new(false);
let ed = editor.clone();
button {
onclick: move || {
dark.update(|d| *d = !*d);
ed.set_dark_mode(dark.get());
},
{move || if dark.get() { "Light mode" } else { "Dark mode" }}
}
}
Keyboard shortcuts
The editor handles its own keyboard input. Every shortcut below comes from the
editor-core keymap (BaseCommandsPlugin/HistoryPlugin), which both the desktop
and web views consult through one path — add a binding there and it works on every
platform. Mod = Ctrl on Windows/Linux, Cmd on macOS.
| Shortcut | Action |
|---|---|
| Mod+B / Mod+I / Mod+U | Toggle bold / italic / underline |
| Mod+E | Toggle inline code |
| Mod+Shift+S | Toggle |
| Mod+A | Select all |
| Mod+Alt+1…6 | Heading 1–6 |
| Mod+Shift+0 | Paragraph |
| Mod+Shift+7 / 8 / 9 | Task / bullet / ordered list |
| Mod+Shift+B | Blockquote |
| Mod+Z / Mod+Shift+Z / Mod+Y | Undo / redo |
| Enter | Split block / new list item |
| Shift+Enter | Insert a hard break (line break within the block) |
| Tab / Shift+Tab | Move between table cells, else indent / outdent a list item |
| Backspace / Delete | Delete backward / forward |
(Copy/cut/paste — Mod+C/X/V, and Mod+Shift+V for paste-as-plain — are handled by the platform clipboard, not the keymap.) Undo/redo is a single, exact history: each undo reverses one logical edit (typing is merged into a group), because every edit is an invertible step.
Markdown shortcuts
As you type, the editor rewrites markdown shortcuts in place (the default
MarkdownInputRulesPlugin, on by default). Block shortcuts fire on a space at the
start of a line; inline mark shortcuts fire when you type the closing delimiter:
| Type | Becomes |
|---|---|
# … ###### | Heading 1–6 |
``` | Code block |
> | Blockquote |
- / * / + | Bullet list |
1. | Ordered list |
[ ] / [x] | Task list (unchecked / checked) |
**bold** / __bold__ | bold |
*italic* / _italic_ | italic |
~~strike~~ | |
==highlight== | highlighted |
`code` | inline code |
Inside a task list, Enter adds a new (unchecked) item, and Enter on an empty
item exits the list — just like bullet/ordered lists. Click a task’s checkbox to
toggle it done (works on desktop and web). To add your own shortcut, append
a mark_input_rule / wrapping_input_rule / textblock_type_input_rule to
markdown_input_rules() (or contribute an input_rules() set from your own plugin).
A complete toolbar
A full example pairing a command toolbar with the editor lives at
examples/markdown-editor/src/main.rs, and examples/ui-zoo/src/sections/editor.rs
shows the same pattern inside the component showcase. The shape is always:
create_editor() once, clone the handle into each button’s onclick, and place a
single Editor {} for the surface.
On the web (rinch-web)
The editor runs in the browser too — the same Editor {} / EditorHandle /
create_editor(), with identical app code. The renderer-agnostic view lives in
rinch-editor-view and projects onto rinch-web’s web_sys DOM (the model is the
single source of truth; the container is deliberately not contenteditable).
rinch-web re-exports the editor, so a web app just imports it:
#![allow(unused)]
fn main() {
use rinch_web::{Editor, create_editor};
#[component]
fn app() -> NodeHandle {
let editor = create_editor();
let ed_bold = editor.clone();
rsx! {
div {
button { onclick: move || { ed_bold.command("toggleBold"); }, "Bold" }
Editor { editor: editor.clone(), content: "<p>Edit me in the browser.</p>" }
}
}
}
#[wasm_bindgen(start)]
pub fn start() {
rinch_web::mount(ThemeProviderProps::default(), app);
}
}
A runnable demo is examples/editor-web (built with trunk serve). The browser
build links no rinch-dom/Parley/automerge — the browser handles layout, text,
and painting.
Supported today: typing, the full command/toolbar surface, keyboard shortcuts,
caret + selection rendering (pixel-accurate overlays), click / double-click (word) /
triple-click (block) / shift-click / drag selection, arrow / word / Home-End /
vertical navigation, clipboard (copy / cut / paste — rich text/html, image, or
plain text), and IME composition (the preedit overlay matches the composing
block’s font).
Clipboard and IME ride a focused, off-screen hidden <textarea> capture target
(created on first editor focus): a plain non-contenteditable <div> receives no
paste/cut/compositionstart events, so the editor focuses the textarea to make
the browser route those native events to it — which also makes focus browser-native
so keys can’t reach the wrong control. Typed characters are still consumed by the
editor’s key handler (and never reach the textarea); only IME composition flows
through it. This mirrors the CodeMirror / ProseMirror hidden-input technique.
Collaboration (optional, collaboration feature)
Two editors can share one live document. Enable the collaboration feature and the
editor projects every local edit onto an Automerge CRDT,
broadcasts the resulting delta, and rebuilds the model from a peer’s delta when one
arrives — so concurrent edits converge. The CRDT adapter
(rinch-editor-collab) is the only thing in a
rinch app that links automerge; default builds link none of it.
rinch = { workspace = true, features = ["desktop", "collaboration"] }
One peer hosts (it owns the starting document); the others join from a
snapshot of it. Each side supplies an outbound closure — where to send a delta a
local edit produced — and feeds a peer’s delta back in with collab_receive:
#![allow(unused)]
fn main() {
// Host: project the current document onto a fresh CRDT and hand peers a snapshot.
let snapshot = host.start_collaboration_host(move |delta| transport.send(delta))?;
// Guest: adopt the host's document and start collaborating.
guest.start_collaboration_guest(&snapshot, move |delta| transport.send(delta))?;
// When a delta arrives from the network, apply it on the main thread:
guest.collab_receive(&delta_bytes);
}
The transport is yours to pick — the seam is just bytes in and bytes out — and it
should deliver deltas reliably and in order (the seam carries incremental
Automerge changes; the full sync protocol for lossy/out-of-order resync is not yet
exposed through the handle). From a background socket/data-channel thread, use the
Send-safe entry point, which marshals the delta onto the main thread for you:
#![allow(unused)]
fn main() {
use rinch::prelude::*;
post_remote_delta(editor_container_id, delta_bytes); // any thread → main
}
is_collaborating(), stop_collaboration(), collab_snapshot() (a fresh snapshot
for a late-joining peer to start_collaboration_guest from), and
collab_take_error() round out the API. The first milestone covers flat
text-blocks + marks (paragraphs, headings, code blocks, bold/italic/link/…); an
edit outside that scope fails loud rather than silently diverging —
collab_take_error() surfaces it, and the CRDT is left untouched (the local edit is
not projected). A runnable two-pane loopback (both editors in one window, no network)
lives at examples/collab-editor-demo/src/main.rs.
On the web
The same adapter runs in the browser — Automerge compiled to wasm. Enable the
collaboration feature on rinch-web:
rinch-web = { path = "...", features = ["collaboration"] }
The EditorHandle collab API is identical to desktop, with two web specifics:
- Inbound is a direct call. Wasm is single-threaded, so a transport callback
(e.g. a
WebSocketonmessage) already runs on the main thread — callhandle.collab_receive(&bytes)(orcollab_receive_for(container_id, &bytes)) directly. There is nopost_remote_deltaon web (that is the desktop runtime’s off-thread marshaller). - Randomness. Automerge mints actor ids via
uuid, which needs a randomness source onwasm32-unknown-unknown. Adduuid = { version = "1", features = ["js"] }to your app (it routes to the Web Crypto API); without it the wasm build won’t compile.rinch-webdeliberately does not pin this — it is the app’s choice.
A runnable two-pane web loopback is examples/collab-editor-web (built with
trunk serve), the browser counterpart of collab-editor-demo.
Where to go next
- Rich-Text Editor — the document model, schema, transactions, commands, history, and the view seam in depth.
examples/markdown-editor— a standalone editor app (great for MCP-driven iteration; built with thedebugfeature).examples/collab-editor-demo— two editors sharing one CRDT, live.