Keyboard shortcuts

Press or to navigate between chapters

Press ? to show this help

Press Esc to hide this help

ContentEditable API

Rinch provides a DOM-level contenteditable system for building rich-text editors. This is the low-level API that powers all text editing — the contenteditable HTML attribute on a <div>, combined with the ContentEditableApi trait for programmatic control.

Two layers: The Rich-Text Editor guide covers the higher-level rinch-editor crate (schemas, extensions, document model). This guide covers the lower-level CE API that it’s built on. Most users will use both — CE for the editing surface, and rinch-editor for serialization/collaboration.

Quick Start

Create a contenteditable element and interact with it:

#![allow(unused)]
fn main() {
use rinch::prelude::*;
use rinch_core::ce::with_active_ce_api;

#[component]
fn my_editor() -> NodeHandle {
    rsx! {
        div {
            // Toolbar
            div {
                button { onclick: move || ce_do(|api| api.toggle_wrap("strong")), "Bold" }
                button { onclick: move || ce_do(|api| api.toggle_wrap("em")), "Italic" }
                button { onclick: move || ce_do(|api| api.set_block_type("h1")), "H1" }
            }
            // Editing surface
            div {
                contenteditable: "true",
                style: "min-height: 200px; padding: 8px; border: 1px solid #ccc;",
            }
        }
    }
}

/// Helper: run a closure on the currently focused CE element.
fn ce_do(f: impl FnOnce(&mut dyn ContentEditableApi) + 'static) {
    with_active_ce_api(|api| f(&mut *api.borrow_mut()));
}
}

Setting contenteditable: "true" on a <div> activates rinch’s built-in editing behavior: cursor rendering, text selection, keyboard input handling, clipboard support, and undo/redo.

How It Works

Keyboard Event
    ↓
InputHandler maps key → EditCommand
    ↓
CeOps mutates EditorDocument (CRDT, source of truth)
    ↓
Affected DOM blocks re-rendered from EditorDocument state
    ↓
CeEvent dispatched to all listeners
    ↓
SelectionChanged + oninput fired
    ↓
Scene marked dirty → repaint
  1. The rinch runtime captures keyboard events on the focused CE element
  2. Keys are mapped to EditCommands (InsertText, DeleteBackward, ToggleBold, etc.)
  3. CeOps — the runtime’s implementation of ContentEditableApi — mutates the EditorDocument (Automerge CRDT)
  4. Only the affected block(s) are re-rendered in the DOM from EditorDocument state
  5. Each mutation dispatches a CeEvent so observers can react
  6. The cursor/selection is updated and the scene is repainted

CRDT-first architecture: Every mutation flows through EditorDocument first, then the DOM is updated as a view. This ensures the CRDT and DOM never diverge, and makes collaboration work automatically — remote changes just load into EditorDocument, then re-render.

ContentEditableApi Trait

The ContentEditableApi trait is the single mutation interface for all CE operations. Every method mutates the DOM and dispatches a corresponding CeEvent.

Text Operations

#![allow(unused)]
fn main() {
fn insert_text(&mut self, text: &str);     // Insert at cursor
fn delete_backward(&mut self);              // Backspace
fn delete_forward(&mut self);               // Delete key
fn delete_selection(&mut self);             // Delete selected range
}

Block Structure

#![allow(unused)]
fn main() {
fn split_block(&mut self);                  // Enter key — split block at cursor
fn set_block_type(&mut self, tag: &str);    // Change block to h1, p, blockquote, etc.
}

Inline Formatting

#![allow(unused)]
fn main() {
fn wrap_selection(&mut self, tag: &str);    // Wrap in <strong>, <em>, etc.
fn unwrap_selection(&mut self, tag: &str);  // Remove formatting wrapper
fn toggle_wrap(&mut self, tag: &str);       // Toggle on/off
}

Supported tags: "strong", "em", "u", "s", "code".

List Operations

#![allow(unused)]
fn main() {
fn indent(&mut self);    // Convert to list item or increase nesting
fn outdent(&mut self);   // Decrease nesting or convert from list item
}

Selection

#![allow(unused)]
fn main() {
fn get_selection(&self) -> CeSelection;
fn set_selection(&mut self, sel: CeSelection);
}

Undo/Redo

#![allow(unused)]
fn main() {
fn undo(&mut self);
fn redo(&mut self);
}

Query Methods

#![allow(unused)]
fn main() {
fn has_active_mark(&self, tag: &str) -> bool;   // Is cursor inside <strong>, <em>, etc.?
fn cursor_block_tag(&self) -> Option<String>;    // Block tag at cursor ("p", "h1", "ul", etc.)
}

Content Interchange

#![allow(unused)]
fn main() {
fn extract_content(&self) -> Vec<BlockData>;         // Read content as structured blocks
fn load_content(&mut self, blocks: &[BlockData]);     // Replace content from blocks
fn load_html(&mut self, html: &str);                  // Replace content from HTML string
fn clear_formatting(&mut self);                        // Strip all inline formatting
}

Accessing the CE API

There are two ways to access the CE API, depending on whether you need to target the focused element or a specific element.

Active CE API (Focused Element)

Use with_active_ce_api() to operate on whichever CE element currently has focus. This is the typical pattern for toolbar buttons:

#![allow(unused)]
fn main() {
use rinch_core::ce::with_active_ce_api;

// Helper function (recommended pattern)
fn ce_do(f: impl FnOnce(&mut dyn ContentEditableApi) + 'static) {
    with_active_ce_api(|api| f(&mut *api.borrow_mut()));
}

// In toolbar buttons:
button { onclick: move || ce_do(|api| api.toggle_wrap("strong")), "Bold" }
button { onclick: move || ce_do(|api| api.set_block_type("h2")), "H2" }
button { onclick: move || ce_do(|api| api.undo()), "Undo" }
}

Returns None if no CE element is focused.

Per-Element CE API (NodeHandle)

Use NodeHandle::with_ce_api() to target a specific CE element. This works whether or not the element has focus — useful for loading initial content:

#![allow(unused)]
fn main() {
let editor_div = rsx! {
    div { contenteditable: "true" }
};

// Load content into a specific CE element
editor_div.with_ce_api(|api| {
    api.borrow_mut().load_html("<p>Hello <strong>world</strong></p>");
});
}

Per-Node Registry

For advanced use cases, access CE APIs by node ID:

#![allow(unused)]
fn main() {
use rinch_core::ce::{with_ce_api_for_node, register_ce_api, unregister_ce_api};

// Access CE API for a known node ID
with_ce_api_for_node(node_id, |api| {
    api.borrow_mut().insert_text("Hello");
});
}

CeEvent System

Every CE mutation dispatches a CeEvent to all subscribed listeners. This is how the editor bridge stays in sync with DOM changes.

Subscribing to Events

#![allow(unused)]
fn main() {
use rinch_core::ce::{subscribe_ce_events, CeEvent};
use std::rc::Rc;

subscribe_ce_events(Rc::new(move |event| {
    match event {
        CeEvent::TextInserted { node_id, offset, text } => {
            println!("Inserted '{}' at node {} offset {}", text, node_id, offset);
        }
        CeEvent::TextDeleted { node_id, offset, length } => {
            println!("Deleted {} bytes at node {} offset {}", length, node_id, offset);
        }
        CeEvent::SelectionChanged { selection } => {
            // Update toolbar active states, etc.
        }
        _ => {}
    }
}));
}

Event Reference

Text Mutations

EventFieldsWhen
TextInsertednode_id, offset, textText inserted at cursor
TextDeletednode_id, offset, lengthText deleted (backspace, delete, selection)
TextNodeCreatednode_id, parent_id, textNew text node created (e.g. first char in empty block)
NodeRemovednode_id, parent_idA DOM node was removed

Selection

EventFieldsWhen
SelectionChangedselection: CeSelectionCursor moved or selection changed

Block Structure

EventFieldsWhen
BlockSplitoriginal_block_id, new_block_id, split_offsetEnter key splits a block
BlockJoinedsurviving_block_id, removed_block_id, merge_offsetBackspace joins two blocks
BlockTypeChangedold_node_id, new_node_id, old_tag, new_tagBlock type changed (e.g. p → h1)

Inline Formatting

EventFieldsWhen
SelectionWrappedtag, wrapper_node_id, wrapped_node_idsSelection wrapped in formatting element
SelectionUnwrappedtag, unwrapped_node_idsFormatting removed from selection

List Structure

EventFieldsWhen
ListItemOutdentedold_li_id, new_block_idList item outdented
BlockIndentedold_block_id, new_li_id, list_idBlock indented into list

Tables

EventFieldsWhen
TableInsertedblock_node_id, rows, colsTable inserted
TableDeletedblock_node_idTable removed

History & Clipboard

EventFieldsWhen
UndoApplied(none)Undo operation completed
RedoApplied(none)Redo operation completed
HtmlPastedcreated_node_idsHTML pasted from clipboard

DomCursor and CeSelection

The CE system uses DOM-level cursor positions, not document-level byte offsets.

#![allow(unused)]
fn main() {
/// A position in the DOM: which text node, and byte offset within it.
pub struct DomCursor {
    pub node_id: usize,   // ID of the text node (or element for empty blocks)
    pub offset: usize,    // Byte offset within the text node
}

/// A selection: anchor (where it started) + head (current caret position).
pub struct CeSelection {
    pub anchor: DomCursor,
    pub head: DomCursor,
}

impl CeSelection {
    fn collapsed(cursor: DomCursor) -> Self  // Cursor (no selection)
    fn range(anchor: DomCursor, head: DomCursor) -> Self
    fn is_collapsed(&self) -> bool
}
}

Key difference from document positions: DomCursor references specific DOM node IDs, not abstract document offsets. Node IDs can change when the DOM is restructured (e.g. block splits, formatting changes).

Data Interchange Types

Use BlockData for structured content interchange (e.g. syncing with EditorDocument):

#![allow(unused)]
fn main() {
pub struct BlockData {
    pub block_type: String,                    // "paragraph", "heading", etc.
    pub attrs: HashMap<String, String>,        // e.g. {"level": "2"} for headings
    pub content: Vec<InlineRunData>,
}

pub struct InlineRunData {
    pub text: String,
    pub marks: Vec<InlineMarkData>,
}

pub struct InlineMarkData {
    pub mark_type: String,                     // "bold", "italic", "code", etc.
    pub attrs: HashMap<String, String>,
}
}

Keyboard Shortcuts

The CE system provides these built-in keyboard shortcuts:

Text Formatting

ShortcutAction
Ctrl+BToggle bold (<strong>)
Ctrl+IToggle italic (<em>)
Ctrl+UToggle underline (<u>)
Ctrl+Shift+XToggle strikethrough (<s>)
Ctrl+EToggle inline code (<code>)

Editing

ShortcutAction
EnterSplit block
BackspaceDelete backward (joins blocks at boundary)
DeleteDelete forward
TabIndent (increase list nesting or insert tab)
Shift+TabOutdent (decrease list nesting)
Ctrl+ZUndo
Ctrl+YRedo

Clipboard

ShortcutAction
Ctrl+CCopy selection
Ctrl+XCut selection
Ctrl+VPaste (HTML preferred, falls back to plain text)

All standard cursor movement keys work: arrow keys, Home/End, Ctrl+arrow for word movement, Shift+arrow for selection, Ctrl+A for select all, Page Up/Down.

Building a Toolbar

A typical pattern for editor toolbars uses reactive signals to track active formatting state:

#![allow(unused)]
fn main() {
use rinch::prelude::*;
use rinch_core::ce::{with_active_ce_api, subscribe_ce_events, CeEvent};

#[component]
fn editor_toolbar() -> NodeHandle {
    let is_bold = Signal::new(false);
    let is_italic = Signal::new(false);
    let block_type = Signal::new(String::from("p"));

    // Subscribe to selection changes to update toolbar state
    subscribe_ce_events(Rc::new(move |event| {
        if let CeEvent::SelectionChanged { .. } = event {
            with_active_ce_api(|api| {
                let api = api.borrow();
                is_bold.set(api.has_active_mark("strong"));
                is_italic.set(api.has_active_mark("em"));
                if let Some(tag) = api.cursor_block_tag() {
                    block_type.set(tag);
                }
            });
        }
    }));

    rsx! {
        div { class: "toolbar",
            button {
                class: {|| if is_bold.get() { "active" } else { "" }},
                onclick: move || ce_do(|api| api.toggle_wrap("strong")),
                "B"
            }
            button {
                class: {|| if is_italic.get() { "active" } else { "" }},
                onclick: move || ce_do(|api| api.toggle_wrap("em")),
                "I"
            }
        }
    }
}

fn ce_do(f: impl FnOnce(&mut dyn ContentEditableApi) + 'static) {
    with_active_ce_api(|api| f(&mut *api.borrow_mut()));
}
}

Loading Initial Content

Use load_html to set the initial content of a CE element:

#![allow(unused)]
fn main() {
#[component]
fn editor_with_content() -> NodeHandle {
    let editor = rsx! {
        div { contenteditable: "true", style: "min-height: 200px;" }
    };

    // Load content (works before the element receives focus)
    editor.with_ce_api(|api| {
        api.borrow_mut().load_html(r#"
            <h1>Welcome</h1>
            <p>This is <strong>rich text</strong> content.</p>
            <ul>
                <li>First item</li>
                <li>Second item</li>
            </ul>
        "#);
    });

    editor
}
}

Extracting Content

Read the current CE content as structured data:

#![allow(unused)]
fn main() {
// As BlockData (for syncing with EditorDocument or serialization)
let blocks = with_active_ce_api(|api| {
    api.borrow().extract_content()
}).unwrap_or_default();

// Process blocks
for block in &blocks {
    println!("Block type: {}", block.block_type);
    for run in &block.content {
        let marks: Vec<_> = run.marks.iter().map(|m| m.mark_type.as_str()).collect();
        println!("  '{}' marks={:?}", run.text, marks);
    }
}
}

Architecture Notes

CeOps

CeOps is the runtime’s implementation of ContentEditableApi. It holds a reference to the RinchDocument and the CE element’s node ID. Created lazily when a CE element first receives focus.

Event Flow

User types "a"
  → KeyEvent captured by winit
  → RinchApp::handle_contenteditable_key()
  → InputHandler maps to EditCommand::InsertText("a")
  → CeOps::insert_text("a")
    → DOM: set_text_content on text node
    → dispatch_ce_event(TextInserted { ... })
  → dispatch SelectionChanged
  → dispatch oninput
  → mark scene dirty → repaint

Thread-Local Storage

The CE system uses thread-local storage for global access:

  • Event dispatcher: dispatch_ce_event() / subscribe_ce_events() — broadcasts events to all listeners
  • Active CE API: set_active_ce_api() / with_active_ce_api() — tracks which CE element has focus
  • CE API registry: register_ce_api() / with_ce_api_for_node() — per-element API lookup

All are thread_local! — safe for single-threaded GUI but not shareable across threads.

Key Source Files

FilePurpose
crates/rinch-core/src/ce.rsCore types: CeEvent, ContentEditableApi, DomCursor, CeSelection, dispatchers
crates/rinch/src/ce_ops.rsCeOps — runtime implementation of ContentEditableApi (CRDT-first mutations)
crates/rinch/src/ce_render.rsBlock rendering, BlockMap, position conversion (EditorPosition ↔ DomCursor)
crates/rinch/src/app/contenteditable/mod.rsKeyboard input handler, cursor management
crates/rinch/src/app/contenteditable/ce_selection.rsSelection, copy/cut, HTML extraction
crates/rinch/src/app/contenteditable/ce_paste.rsHTML paste handling
crates/rinch/src/app/contenteditable/ce_navigation.rsCursor navigation
crates/rinch/src/app/contenteditable/ce_virtualization.rsLarge document virtualization
crates/rinch-editable/src/Generic editing primitives (EditCommand, InputHandler)