Keyboard shortcuts

Press or to navigate between chapters

Press ? to show this help

Press Esc to hide this help

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 desktop cargo feature). The editor core is renderer-agnostic; a web view (over the browser’s native contentEditable) is a planned follow-up. There is no contenteditable HTML attribute and no DOM-level editing API to wire up — the Editor component 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

PropTypePurpose
editorOption<EditorHandle>A handle from create_editor(). Omit it and the component creates its own self-contained editor.
contentStringInitial 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:

CategoryCommands
Inline markstoggleBold, toggleItalic, toggleUnderline, toggleStrike, toggleCode, toggleHighlight, toggleSubscript, toggleSuperscript
Block typessetParagraph, setHeading1setHeading6, setCodeBlock
AlignmentsetTextAlignLeft, setTextAlignCenter, setTextAlignRight, setTextAlignJustify
ContainerstoggleBulletList, toggleOrderedList, toggleTaskList, wrapInBlockquote
ListssinkListItem (indent), liftListItem (outdent)
InsertsinsertHorizontalRule, insertHardBreak, insertTable
TablesaddRowAfter, addRowBefore, addColumnAfter, addColumnBefore, deleteRow, deleteColumn, deleteTable, mergeCells, splitCell
LinksremoveLink
Historyundo, 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, and handle.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 to left along 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() } }
}
MethodPurpose
load_html(&str) -> boolParse schema-whitelisted HTML and replace the document. Returns false if it doesn’t parse into valid content.
doc() -> NodeThe 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) -> boolAdd 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.

ShortcutAction
Mod+B / Mod+I / Mod+UToggle bold / italic / underline
Mod+EToggle inline code
Mod+Shift+SToggle strikethrough
Mod+ASelect all
Mod+Alt+1…6Heading 1–6
Mod+Shift+0Paragraph
Mod+Shift+7 / 8 / 9Task / bullet / ordered list
Mod+Shift+BBlockquote
Mod+Z / Mod+Shift+Z / Mod+YUndo / redo
EnterSplit block / new list item
Shift+EnterInsert a hard break (line break within the block)
Tab / Shift+TabMove between table cells, else indent / outdent a list item
Backspace / DeleteDelete 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:

TypeBecomes
# ###### Heading 1–6
```Code block
> Blockquote
- / * / + Bullet list
1. Ordered list
[ ] / [x] Task list (unchecked / checked)
**bold** / __bold__bold
*italic* / _italic_italic
~~strike~~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 WebSocket onmessage) already runs on the main thread — call handle.collab_receive(&bytes) (or collab_receive_for(container_id, &bytes)) directly. There is no post_remote_delta on web (that is the desktop runtime’s off-thread marshaller).
  • Randomness. Automerge mints actor ids via uuid, which needs a randomness source on wasm32-unknown-unknown. Add uuid = { version = "1", features = ["js"] } to your app (it routes to the Web Crypto API); without it the wasm build won’t compile. rinch-web deliberately 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 the debug feature).
  • examples/collab-editor-demo — two editors sharing one CRDT, live.