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 and web. On desktop the editor arrives with the
desktopcargo feature; in the browserrinch-webre-exports it (see On the web below) — the app code is identical, because both share the renderer-agnostic view inrinch-editor-view. 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. |
read_only | bool | Mount the editor read-only. false (the default) leaves the handle’s switch as it is, so a handle locked before mount stays locked. Change it afterwards with handle.set_read_only(..). |
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 |
| Deleting | deleteSelection, deleteCharBackward, deleteCharForward, deleteWordBackward, deleteWordForward |
| 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. Text typed right after a link is not part of it (linkis a non-inclusive mark), so a link does not grow as the user keeps typing past it. The one exception is a link that runs straight into a different link: text typed at that seam continues the first one (see inherited marks).
The word deletes remove from the caret to the boundary the word motion (Ctrl+Left / Ctrl+Right) lands on, taking the whitespace between: in
hello wor|ldbackward leaveshello |ld. A mark boundary is not a word boundary. Where there is no word to take they are Backspace / Delete: a non-empty selection is deleted as it stands, and at a textblock’s start / end they join (or lift), as Backspace and Delete do there. An inline atom — an image, a hard break — is a boundary of its own: one right beside the caret goes alone, and a word beyond one is not taken with it. There are no line delete commands. On the web the browser’sdeleteSoftLine*(a soft keyboard’s line delete, Cmd+Backspace / Cmd+Delete) deletes to the edge of the visual line the caret is drawn on — on a wrapped paragraph, normally only the current line’s prefix or rest — anddeleteHardLine*to the textblock’s edge (issue #301). Home / End go to the visual line’s edges on both backends. The web finds the edge from caret geometry: the first and last positions whose caret is drawn on the caret’s own line, which are the line’s logical edges — a right-to-left line starts at its right edge. Desktop reads the line from the textblock’s text layout — the line holding the caret, the upper one for a caret drawn at the end of a soft-wrapped line — and takes its logical start and end: after a hanging space at a soft wrap, before a hard break (Shift+Enter).Caret affinity at a soft wrap. The end of one visual line and the start of the next are one model position, so a caret there could be drawn in either place. The editor keeps a hint beside the selection, as CodeMirror and a browser do: End (landing on the wrap point itself, after any hanging space), a press past a wrapped line’s end, and an Up / Down move whose column lies past the target line’s end draw it at the end of the upper line; Home and anything else draw it at the start of the lower one. So Home twice stays on its line, Home then End reaches that line’s end, End on a word broken by
overflow-wraplands after the line’s last letter (typing appends to the upper line), and a soft-line delete backward right after Home deletes one character, as Chrome does. The hint is view state, not part of theSelection: it applies only while the selection is the one it came with, so typing, an edit before the caret in its own paragraph, undo, a load or a plainset_selectionreturns the caret to the lower line’s start. An edit that leaves the caret’s paragraph unchanged up to the caret — in an earlier paragraph, a collaborator typing above or after the caret — carries the hint along, so the caret stays at the end of its line. A caret right before a hard break (Shift+Enter) is always drawn at the end of its own line. A paragraph that ends in a hard break shows the empty line after it, where the caret after the break sits: a browser lays<p>a<br></p>out as one line (a line after the last forced break needs something after it), and so does rinch-dom, so the view renders a second<br data-pm-trailing-break>after the break, as ProseMirror does — and after a code block’s text that ends in a newline, for the same reason. It is not part of the document and never reachesdoc(), the HTML or the markdown. Set it yourself withEditorHandle::set_selection_with_affinity; read it withcaret_affinity(), andcaret_rectat the head draws with it.Neither backend hit-tests points on the line (the web uses collapsed
Rangerects, desktop the text layout), so a caret line scrolled out of the editor’s scroller or off the page behaves as a visible one (#1026, #1107), and a hard break (Shift+Enter) always ends the line: Home after it lands after it, End before it stops before it.Where this still differs from a browser field:
- Desktop, Home and End beside an image: Home on a line that starts with an image lands after it, and End on a line that ends with one lands before it (#1116). The caret beside an image is drawn on its side, and a drag or Up/Down onto an image lands on the half it reaches (#1104); a hard break is resolved on both sides on both platforms (#1099, #1025), and the web resolves both sides of an image too — a click, Home, a soft-line delete, and an IME commit or autocorrect right after one.
- On a line that mixes directions the answer is the line’s logical edge, which can differ from a browser field’s: at the end of a line whose last run is right-to-left, Chrome’s End stops before the trailing space where rinch’s lands after it, on the wrap point.
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. No markup is refused, however broken: its text is read, into valid content (see What the HTML reader keeps). Returns false only when the load itself is refused (a read-only collaborating editor). |
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. |
on_image_input(cb) / insert_image_at(&anchor, src, alt) | Be offered pasted and dropped pictures and answer with the src to insert, at once or later. See Pasted and dropped pictures. |
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 the selection is on, for pre-filling an “edit link” dialog: for a range, the first link in it; for a caret, the link text typed there would carry. A link is not inclusive, so a caret inside it answers its href and a caret at its start or right after its last character answers None — except where it runs straight into a different link, where the caret is in the first. |
paste(&PasteContent) -> bool | Paste text/plain and/or text/html over the selection the way the user’s paste does: your plugins first, then the default. See Seeing and rewriting a paste. |
replace_selection_with_html(&str) | Replace the selection with parsed HTML (the default rich paste; no plugin sees it), fitted to where it lands. See What a paste does. |
selection_clipboard() | The current selection serialized as (html, plain_text) for the clipboard. A selection inside a list is copied with its list, one across table cells with its table. |
anchor_selection() -> SelectionAnchor | Capture the selection for a later insertion, kept pointing at the same content as the user keeps editing. See Pasting is asynchronous. |
HTML is schema-whitelisted on load: known block tags become nodes and known
inline tags become marks; an element the schema has nothing for is read through for
its content, and unknown attributes, <script>, inline event handlers and the like
are dropped at parse time. The document can only ever hold structure the schema
allows, and it holds all of the text: see
What the HTML reader keeps.
Your own plugins and inline decorations
add_plugin(Rc<dyn Plugin>) -> bool installs a plugin of your own — a
spellchecker, a search highlighter. It rebuilds the editor’s state over the
current document and selection, which discards the undo history, so call it
on a freshly created handle, before content is typed. It is not an edit: a
read-only editor (collaborating or not) accepts it, nothing is sent to peers and
on_change does not fire. A plugin whose key is already installed is refused
(false), and a refused call changes nothing.
A plugin marks ranges of text through its decorations(), without touching the
document:
#![allow(unused)]
fn main() {
use rinch_editor_core::decoration::{Decoration, DecorationSet};
impl Plugin for Spellcheck {
fn key(&self) -> PluginKey { PluginKey("my-app.spellcheck") }
fn decorations(&self, state: &EditorState) -> DecorationSet {
DecorationSet::new(
self.misspelled(&state.doc)
.map(|(from, to)| {
Decoration::inline(from, to, Attrs::new().with("class", "pm-spell-error"))
})
.collect(),
)
}
}
}
The view wraps each decorated stretch in a <span data-pm-deco class="…">.
The built-in stylesheet draws pm-spell-error (red) and pm-grammar-error
(green) as wavy underlines, on desktop and on the web; any other class is yours
to style. Decorations are recomputed from every new state, and the editor does
not move a range for you when text is inserted before it — a plugin that
caches ranges maps them through tr.mapping() in its apply.
Plugin code runs untracked (issue #943). decorations, apply,
init_state, handle_paste and a plugin’s commands run synchronously inside
whatever app effect called the handle (a set_selection, a command, an
insert_text), and a signal they read does not become that effect’s
dependency. The flip side: a plugin is not re-asked when a signal it reads
changes. A spellchecker with an on/off switch re-runs its decorations by
dispatching something when the switch moves, from an effect of its own:
#![allow(unused)]
fn main() {
let ed = editor.clone();
Effect::new(move || {
let _ = spellcheck_on.get();
// Any transaction re-asks every plugin's decorations; this one changes nothing.
ed.update(|state| Some(state.tr()));
});
}
The build closure you pass to update is the exception: it is your own code,
and what it reads tracks as usual — including what plugin code it calls reads
(update(|s| { s.apply(..); … }) runs the plugins’ apply as your code).
A right press over the editor places the caret before an app’s
data-oncontextmenu handler runs, on both backends, so a handler that draws its
own suggestions menu reads the pressed word from selection().
Seeing and rewriting a paste
A plugin can claim a paste before the editor inserts it, through
Plugin::handle_paste. It is handed the editor’s state at the paste and the
clipboard’s text/plain and text/html flavours (PasteContent), and answers
the transaction to apply instead, or None to leave the paste alone. Every
paste the platform reports as a paste event reaches it: Ctrl+V / Cmd+V,
Ctrl+Shift+V and the context menu’s Paste on desktop, and the browser’s paste
event on the web, all go through EditorHandle::paste, which asks the plugins
first. A mobile keyboard’s clipboard chip is the exception: it inserts text
directly, as if typed, and does not reach the hook.
A URL pasted over selected words that links them instead of replacing them:
#![allow(unused)]
fn main() {
use rinch_editor_core::{
AttrValue, Attrs, EditorState, Mark, PasteContent, Plugin, PluginKey, Transaction,
};
struct LinkOnPaste;
impl Plugin for LinkOnPaste {
fn key(&self) -> PluginKey { PluginKey("my-app.link-on-paste") }
fn handle_paste(&self, state: &EditorState, paste: &PasteContent) -> Option<Transaction> {
let url = paste.text.as_deref()?.trim();
if !url.starts_with("https://") || url.contains(char::is_whitespace) {
return None; // not ours: the default paste
}
let (from, to) = (state.selection.from().0, state.selection.to().0);
if from == to {
return None; // nothing selected: paste the URL as text
}
let link = state.schema().mark_type("link")?.clone();
let mut tr = state.tr();
tr.add_mark(from, to, Mark::new(link, Attrs::from_iter([("href", AttrValue::from(url))])))
.ok()?;
Some(tr)
}
}
editor.add_plugin(Rc::new(LinkOnPaste));
}
- Order. Plugins are asked in the order they were installed (the built-ins
first, then
add_plugin’s, which claim no paste); the firstSomewins and later plugins are not asked. With no claim the default runs:text/htmlparsed into structure, elsetext/plainone paragraph per line. - Flavours. Either may be
None. A browser’s copy usually carries both, so read the text when you want to know whether the paste is a URL: a copied link’s html is an<a>whose text may be its title. Ctrl+Shift+V offers the text alone. An image-only paste (a screenshot) never reaches the hook. On desktop the text beside the html comes from the same clipboard read (paste_rich_with_text_async). - Where.
state.selectionis where the paste lands; on desktop that is where Ctrl+V was pressed, mapped through anything typed while the clipboard was read (see Pasting is asynchronous). - What a claim is. One transaction: one undo step,
on_changeif the document changed, the caret scrolled into view, and on a collaborating editor recorded and broadcast like any local edit.Some(state.tr())swallows the paste. A read-only editor refuses the claim as it refuses every edit (and on desktop starts no clipboard read at all). - Borrowing.
handle_pasteruns while the editor is borrowed, like a command, so it must not call theEditorHandle. Anything it needs (a note’s title for a link, say) has to be at hand already.
EditorHandle::paste(&PasteContent) is the same entry point for an app of its
own: a “Paste link” menu item that read the clipboard itself goes through the
plugins exactly as Ctrl+V does.
Pasted and dropped pictures
By default a bitmap pasted into an editor becomes an image whose src is a
data: URL, and image files dropped on a desktop editor go to the app’s own
onfiledrop handler. An app that keeps pictures somewhere of its own (a blob
store, an upload) asks to be offered them instead:
#![allow(unused)]
fn main() {
let editor = handle.clone();
handle.on_image_input(move |input: ImageInput| {
// input.bytes: the encoded file (a pasted bitmap arrives as PNG)
// input.mime: "image/png", "image/jpeg", "image/gif" or "image/webp"
// input.name: the dropped file's name; None for a paste
let src = store_blob(&input.bytes)?; // answer at once...
Some((src, String::new())) // ...with (src, alt)
});
}
The editor inserts an image with that src where the picture was aimed: the
selection at the paste, or the caret at the drop point. When storing takes a
round trip, keep the input, return None, and finish later:
#![allow(unused)]
fn main() {
handle.on_image_input(move |input| {
let editor = editor.clone();
store_blob_async(input.bytes, move |src| {
editor.insert_image_at(&input.anchor, &src, "");
});
None
});
}
input.anchor is the place the picture was aimed at, kept pointed at the same
content while the person, or a collaborating peer, types on. When the answer
arrives the picture goes there and the person’s caret stays theirs: if they
have moved on, their selection is kept (shifted by the picture when it lands
before it), nothing scrolls, and the picture is an undo step of its own. If
they have not moved, the caret ends up after the picture, as after any insert.
insert_image_at answers false, changing nothing, when the place is gone
(the document was replaced, or a peer removed or restructured the block it was
in), when the editor has become read-only, or when the place no longer takes an
image. Delete what you stored for it then: nothing references it. Returning
None and never inserting is a refusal: nothing is inserted, and no data:
URL is made.
ImageInput and ImageInputSource are #[non_exhaustive]: read the fields
you need (or destructure with ..), and give a match on the source a
wildcard arm.
Pictures drawn over, and controls over a picture
The <img> of an image node asks the image loader for its node’s src. An app
that shows something else for some pictures (a picture with marks drawn over it)
chooses the source from the node’s attributes, once, for every editor on the
thread:
#![allow(unused)]
fn main() {
rinch::editor::set_image_source(|attrs| {
let id = attrs.get_str("data-annotation-id").filter(|i| !i.is_empty())?;
Some(format!("{}#annotation={id}", attrs.get_str("src")?))
});
}
None keeps the src. The document is untouched (copy, export and collaboration
still see the src); only the picture shown changes. The answer is what the
loader is asked for and what rinch::image::reload_image names, so two images with
one src and different attributes are two pictures, each loaded and reloaded on
its own: reload_image("…#annotation=a1") asks again for that one alone. It is asked
whenever an <img> is built or its node’s attributes change, with the editor
borrowed, so it must be a function of attrs alone. clear_image_source() takes
it away. In the browser it is rinch_web::set_image_source.
Installed while a component renders, the source function is removed when that
component unmounts (a later install is never clobbered by an earlier unmount);
installed from main, it keeps app lifetime. It is per thread, not per document:
two documents on one thread (two embedded contexts) share it, the last install
winning for both. Pictures already shown keep the source they were given until
their <img> is rebuilt or their attributes change; handle.load_doc(handle.doc())
makes an editor’s pictures ask again.
To float controls over a picture (a button in its corner), an app hears where the pointer is:
#![allow(unused)]
fn main() {
let eye: Signal<Option<(Pos, rinch::reactive::ElementBounds)>> = Signal::new(None);
editor.on_image_hover(move |hover| eye.set(hover.map(|h| (h.pos, h.rect))));
}
| Method | Purpose |
|---|---|
on_image_hover(impl Fn(Option<&ImageHover>)) | The pointer came onto an image (Some), moved straight onto another (Some), or left this editor’s images (None). Called only when that answer changes; an image that was edited, or that a scroll or relayout moved, is reported again on the next move. |
image_at_host(host_id) -> Option<(Pos, Attrs)> | The image whose <img> is that host element (what the runtimes ask). |
ImageHover carries pos (before the image: where a SetNodeAttrStep for it
goes), attrs, and rect, the box the <img> is painted in, in the same frames
as LinkHover::rect. The controls are the app’s own elements outside the editor,
so moving onto them leaves the image and reports None; keep them shown while the
pointer is over them with their own onmouseenter / onmouseleave. Not reported
during a drag-select or a drag-and-drop; free while no editor has the callback.
Hover is measured on pointer moves only: a wheel scroll moves the picture under a
resting pointer and reports nothing until the pointer moves, so hide the controls
when the content scrolls (an onscroll on the scroller). On desktop the pointer
leaving the window reports nothing either (the browser reports None). Like
on_change, the callback belongs to the component that registered it: once that
component unmounts it is not called again, and one registered outside any render
keeps app lifetime.
With the callback registered:
- A paste whose clipboard holds a bitmap is offered. That includes a browser’s
“Copy image”, which also puts an
<img>on the clipboard as html: when the html is pictures and nothing else, the bitmap is what the app gets. - Image files dropped on the editor are offered one by one, in order, each as
soon as it is read, and the editor takes the keyboard. A file is a picture
when its extension says so (
png,jpg,jpeg,gif,webp), its first bytes agree, and it is at most 64 MiB (the file is read whole into memory and handed over as oneVec). The app’sonfiledrophandler gets every other file of the drop: the PDF dropped with a photo, a text file that is only named.png, a picture over the limit. A drop with no picture in it, or on a read-only editor, is the handler’s whole, as before. - A pasted bitmap has no size limit of its own: it is whatever the clipboard held, encoded as PNG on the UI thread (about 50 to 90 ms for a 4K screenshot).
- Where an image cannot be inserted (the caret is in a code block), nothing is offered: the app is not asked to store a picture that would be refused.
- An html paste that holds text as well as pictures is an ordinary paste: its
<img>elements keep thesrcthey came with.Plugin::handle_pasteis where an app rewrites or strips those.
The list above is the desktop’s. The browser build offers pictures through
the same callback, with these differences. There a picture is a File: a
pasted image file is offered as its own bytes with the browser’s name for it,
and image files dragged onto the editor from outside the page are offered at
the drop point, the editor taking the drop (and the keyboard) so the browser
does not open the file in place of the page. A file counts as a picture when
the browser calls it image/* (so an SVG or an AVIF is offered, typed as the
browser typed it), there is no size limit, and the other files of a mixed
drop are ignored: the browser build does not dispatch onfiledrop.
On a read-only editor with the callback, a drop is prevented and nothing
else happens: nothing is offered, the caret and the focus stay. It has to be
prevented, or the browser would navigate the tab to the dropped file and the
person would lose the app. With no callback a pasted image is a data: URL,
as before, and a file drop is the browser’s (which opens the file; that is
#1458).
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" }}
}
}
Read-only
set_read_only(true) turns the editor into a document people can read, select and
copy, and cannot change — what readonly is to an <input>. It is a runtime switch:
flip it whenever access changes, mounted or not, and ask with is_read_only().
#![allow(unused)]
fn main() {
let editor = create_editor();
editor.load_html(¬e_html);
editor.set_read_only(!can_edit); // at open…
// …and again whenever the answer changes (a role was granted or revoked):
editor.set_read_only(!now_can_edit);
rsx! { Editor { editor: editor.clone() } } // or: Editor { read_only: true, content: … }
}
Refused (each answers false; document, undo history and on_change untouched) | Still works |
|---|---|
| Typing, IME commit, Backspace/Delete, Enter | Placing and moving the caret, pointer and keyboard selection, selectAll |
| Paste, cut, pasted images | Copy (selection_clipboard, Ctrl+C, the context menu’s Copy) |
Every document-changing command(..) — marks, block types, lists, indent/outdent, tables — and the keys bound to them | Every query: doc(), is_mark_active, current_block_type, … |
undo / redo | load_html / load_doc — the app showing a document is not the user editing one |
toggle_link, insert_image, a task checkbox click, any update(..) transaction that changes the document or sets stored marks | Remote collaboration updates (collab_receive), exactly as in an editable editor |
It makes no difference who asks: a toolbar button calling command("toggleBold") and
a keystroke go through the same gate, which sits at the one place every local change
lands rather than in each input handler. can_run(name) answers false for a refused
command, so a toolbar that greys its buttons from it goes inert with the switch; a
toolbar of your own should also read is_read_only(). The built-in context menu
offers Copy and Select all only, the OS input method stays off (no IME candidate
window), and on the web the hidden capture field is readonly, so no soft keyboard
is raised and the browser’s own menu drops Paste and Cut.
The caret stays visible — a reader selects and copies with it. The mounted container
carries data-pm-readonly="true" while the switch is on, for your own styling; the
built-in stylesheet uses it to hide the empty-editor placeholder.
Switching it on drops pending typing state (a clicked “Bold” waiting for text, an IME preedit). Switching it off gives everything back, undo history included.
Keeping the caret in view
A focused editor scrolls its caret into view after the user moves it or edits
at it: typing, Enter, Backspace/Delete, every command and the keys bound to them,
arrow keys and clicks, set_selection, paste, IME commit, insert_image and
toggle_link. For a node selection the selected
node’s outline is what is revealed, and for a cell selection the cell under its
moving (head) corner.
The scroll is the minimal one (nearest): a caret already in view moves nothing,
and one out of view is brought just inside the edge it left by, instantly rather
than smoothly. On the web that is the browser’s scrollIntoView, which scrolls
every scrollable ancestor, the page included. On desktop only the caret’s
nearest scroll container scrolls (issue #842), so an editor whose scroller is itself
off screen stays off screen.
It deliberately does not scroll when the caret’s position on screen changes without the user moving it — a user who scrolled away to read something is left where they are:
- a collaborator’s edit arriving (
collab_receive), even one above the caret; load_html/load_doc, andadd_plugin(neither is an edit);- a window resize that reflows the text, and the user’s own scrolling (including a long, virtualized editor measuring the blocks it scrolls past);
- an app transaction through
update(..)that edits the document and lets the selection be mapped rather than setting it. A transaction that callstr.set_selection(..)— astr.insert_textdoes — scrolls like typing; to reveal the caret after an edit of your own, set the selection explicitly, even to where it already is (let sel = tr.selection(); tr.set_selection(sel);); - an edit a read-only editor refuses. A read-only editor’s caret moves still scroll: they are real selection changes;
- focus on its own. A click that places the caret scrolls (it moved the
selection); a click that focuses the editor without moving the caret — a task
checkbox, a right-click on an image — does not jump to where the caret was, and
neither does
focus()when nothing is owed; - an editor that is not focused: its caret is not drawn, so a programmatic
set_selectionon it scrolls when it is next focused, not before.
Not covered: the moving end of a text range (Shift+arrow) is not revealed, and
moving the caret into a block of a virtualized editor that has never been laid out
(Ctrl+End from the top of a very long document) does not scroll to it until the
block is on screen (issue #845). An app that wants either on screen asks for it with
scroll_into_view, below.
Setting the selection from code
set_selection, command("selectAll") and any other change to the selection made
by app code — from a toolbar button, a menu, a timer or an effect — draws the caret
and the selection highlight without an input event in the editor. On desktop
(and in an embedded RinchContext) the change wakes the runtime for one layout and
overlay pass, like any other change to the page; on the web the overlays are
redrawn in a microtask, once the code that made the change has returned. Only a
focused editor draws a caret or a highlight, on both backends: set on an editor
that is not focused, the selection is shown when it is focused.
Focus and scrolling from code
Opening a note at a link — select the words the link quotes, bring them on screen, put the keyboard there — takes three calls, and no click:
#![allow(unused)]
fn main() {
handle.set_selection(Selection::text(from, to));
handle.scroll_into_view(from, to);
handle.focus();
}
scroll_into_view(from, to) scrolls so that the range is on screen: its start,
and as much of the rest as fits, 16px inside the scroller’s edge (where the
document extends that far). from == to reveals a caret position. A range already
in view moves nothing. It needs no focus and touches neither the selection nor
focus, and it is the only way to reveal a range: set_selection brings a caret
into view on its own, but never a range. The ends may be given in either order; a
position between blocks reveals the nearest text.
It uses the scroll the caret uses, on two hidden boxes the view places over the
range’s end and then its start, so the rules above apply: on desktop it happens
after the next layout (the call wakes the runtime for one, from an effect or a timer
as well as from an event handler) and moves the nearest scroll container; on the
web it happens during the call and scrollIntoView moves every scrollable
ancestor, the page included. A virtualized desktop editor lays out the blocks holding
from and to for it. The request waits on the handle until there is geometry for
its start (a block edited since the last layout has none on desktop); if the end has
none by then, the start alone is revealed. A later call replaces it, local edits
carry it along (text typed exactly at either edge stays outside the range), and
load_html / load_doc drop it.
A request that cannot be fulfilled does not wait for good. In an editor that is not
rendered (display: none, an inactive tab) the boxes are laid out at zero size on
both backends, so the request is used up at once and scrolls nothing; one whose start
has no geometry at all for REVEAL_PATIENCE (8) overlay passes in a row is dropped.
Either way nothing scrolls when the editor is shown later: ask again then.
scroll_into_view_aligned(from, to, align) chooses where the range lands.
ScrollAlign::Nearest is scroll_into_view itself. ScrollAlign::Fraction(f) puts
the start’s line f of the way down the scroll container’s visible height — its
padding box, inside the border, as a browser measures it (0.0 the top edge, 1.0 / 3.0 a third down; clamped to 0.0..=1.0), never nearer than
16px to either edge, and it scrolls even when the range is already in view. The
scroll is clamped to what the content allows, so a range near the top of the
document stays near the top of the view and one near the end sits lower. A range
taller than the space below f still puts its start at f. To open a note at a link
with some context above the words:
#![allow(unused)]
fn main() {
handle.set_selection(Selection::text(from, to));
handle.scroll_into_view_aligned(from, to, ScrollAlign::Fraction(1.0 / 3.0));
handle.focus();
}
It waits, carries and drops exactly as scroll_into_view does, and scrolls the same
containers on desktop (the nearest scroll container, vertically). On the
web it sets the nearest scroll container’s scrollTop (the page’s, when no
ancestor scrolls) and then brings the start into the view of the scrollers further
out, the page included, the “nearest” way. One limit: in a virtualized desktop
editor the blocks the scroll brings into the window were laid out at their
estimated heights when the scroll was computed and settle to their measured ones
after it, so the start can land a fraction of a line off f. Both are built on
NodeHandle::scroll_to_fraction(fraction, margin)
(DomDocument::request_scroll_to_fraction), which any element can use.
focus() gives the editor the keyboard as a press in it would — the previous
owner loses it the same way (an <input> commits its change, another editor hides
its caret) — without moving the selection. It never asks for a scroll of its own,
so giving the keyboard back after a dialog closes does not jump to a caret the user
scrolled away from. It does perform a caret scroll already owed: a
set_selection of a caret (or an edit through update that sets the selection) on
an editor that is not focused asks for its caret to be shown, and only a focused
editor draws a caret, so that scroll waits and happens when focus() lands. That is
what makes set_selection(Selection::cursor(pos)); focus() show the caret. A
range selection owes nothing (a range is never revealed on its own), which is why
the deep-link example above asks with scroll_into_view. The caret or selection
highlight is drawn where the selection already is. On desktop it posts the same
focus request NodeHandle::focus posts (which now focuses an editor container too),
and the runtime applies it through the focus arbiter after the current event or
effect; on the web it focuses the editor’s hidden capture textarea during the
call (preventScroll), as a mousedown does.
blur() is the other half: if this editor holds the keyboard, it lets go and
nothing holds it — what a press on page content that takes no focus does. Keys stop
reaching it, and its caret and selection highlight are hidden as on any blur; the
selection is kept, so a later focus() shows it again. If the editor does not hold
the keyboard, nothing happens: another editor or a field that has it keeps it. An app
with several editors calls it when a pane stops holding a document:
#![allow(unused)]
fn main() {
// The person focused an empty pane: no editor takes keys now.
for pane_editor in &editors {
pane_editor.blur();
}
}
On desktop it posts a release that the runtime applies at the same point as
focus()’s request, and the two are applied in the order they were called:
focus(); blur() in one handler or effect leaves nothing focused and
blur(); focus() leaves the editor focused. A release never displaces a focus
request posted by anything else (an overlay opening still moves focus into itself),
and several releases in one turn are all applied. Both are applied right after a
click or Enter/Space handler, otherwise before the next key press, pointer press or
IME event is routed — a key already queued behind the Escape whose on_key called
blur() does not reach the editor — and at the latest on the next turn of the event
loop. On the web it blurs the hidden
capture textarea during the call if this editor is the focused one; focus goes to
the page body, as a browser’s own blur() does.
Effects queued by a signal write earlier in the same handler run before the blur, as
they do before any NodeHandle call, so open.set(false); editor.blur() leaves
nothing focused even when the closing overlay hands focus back to the editor. A
composition in progress ends with the blur and leaves no preedit behind: the web
commits it (the browser’s compositionend reaches the editor before it lets go),
desktop drops it.
All three are no-ops before the editor is mounted, and nothing is remembered for the
mount: call them after the Editor {} has rendered.
Links: clicking and hovering
The editor never follows a link by itself: what a link means — a URL to open in a browser, another document in your app — is the app’s business. Two callbacks tell the app what the pointer does with links:
#![allow(unused)]
fn main() {
let ed = editor.clone();
editor.on_link_click(move |click| {
if !click.primary {
return false; // a plain click puts the caret in the link, as always
}
open_link(&click.link.href);
true // claimed: the caret does not move
});
let tooltip: Signal<Option<(String, rinch::reactive::ElementBounds)>> = Signal::new(None);
editor.on_link_hover(move |hover| {
tooltip.set(hover.map(|h| (h.link.href.clone(), h.rect)));
});
}
| Method | Purpose |
|---|---|
on_link_click(impl Fn(&LinkClick) -> bool) | A single primary-button press on a linked character, before the caret is placed. Return true to claim it: no caret move, no drag-select, no selection change (the editor still takes focus). false and the press is an ordinary one. Called in a read-only editor too. |
on_link_hover(impl Fn(Option<&LinkHover>)) | The pointer came onto a link (Some), moved straight onto a different link (Some), or left this editor’s links (None). Called only when that answer changes, never once per move. |
link_at(pos) -> Option<LinkSpan> | The link carrying the character that starts at pos, as the whole run. |
A LinkSpan is the whole link as the reader sees it: href, title (None when
empty) and its from..to range, taken across other formatting, so a link with a
bold word in the middle is one span. LinkClick carries the span and the modifiers
(ctrl, meta, shift, alt) plus primary, the platform accelerator — Cmd on
macOS, Ctrl elsewhere — which is the usual “open the link” chord. LinkHover
carries the span and rect, the box around the link’s painted run (a wrapped link
is the union of its lines), measured when the pointer came onto it: logical window
pixels on desktop, the frame bounds_signal() and root-level absolutely positioned
popups use; viewport client pixels (getBoundingClientRect) in the browser.
Both are decided by the character under the pointer, not by the nearest caret
position. The two differ at a link’s edges: a caret at either edge of a link is not
in it (a link is non-inclusive, so active_link_href() answers None there and
typing there is plain), but the pointer over the right half of the link’s last
letter, or over its first letter, is on it — and the pointer over the space after
the link is not.
Not reported: a double or triple press (they select a word or a block), the
secondary button (the context menu keeps its own link handling), a press on an image
inside a link (it selects the image), and hover during a drag-select or a
drag-and-drop. A callback runs with no internal borrow held, so it may re-enter the
handle — load another document, read the selection. It also runs untracked: a
signal it reads never becomes a dependency of an effect that happened to call
dispatch_link_click (issue #931). Like on_change, each belongs to the component
rendering when it was registered: once that component unmounts the callback is not
called any more (a press is not claimed, and the editor counts as having no click
callback), and one registered outside any render keeps app lifetime. A pointer move pays nothing for
hover while no editor on the thread has an on_link_hover callback; with one it
reuses the move’s own hit test on desktop.
The callbacks are not run inside a batch() (like on_change): a callback that
writes several signals flushes effects after each write. Wrap its body in
batch(|| …) if that matters.
In the browser an editor link is a real <a href>, and the click that follows
a press would navigate even though the editor cancels the press. Whether the
browser follows it is decided per editor:
| Editor | A click, middle click or Ctrl/Cmd+click on a link |
|---|---|
| Editable | Never navigates. A click in an editable editor is an edit gesture (it placed the caret), so following the link is always wrong. |
Read-only, with an on_link_click callback | Never navigates. The callback is where the app says what a link does, even when it declines the press. |
Read-only, no on_link_click callback | The browser’s own behaviour: a click follows the link, and a middle click or Ctrl/Cmd+click opens it in a new tab. |
The same rule covers an <a href> inside the editor that is not a link mark, but
such an anchor is never offered to on_link_click. The right-click menu is never
touched: it is the browser’s own link menu, whose “Open link” still works (see
On the web).
Keyboard activation. The editor container is not contenteditable, so an
editor link is an ordinary Tab stop, and the browser turns Enter on a focused link
into a click with no press before it. That click is offered to on_link_click with
the whole link and the modifiers held; claiming it prevents the navigation. With no
claim, the table above decides.
Migration note (web). Before link events, a click on a link in an editable web editor followed the link as it placed the caret, which was a bug; it no longer does. A read-only editor used as a document viewer keeps native link behaviour as long as it registers no
on_link_click. Registering one makes the app responsible for every link in that editor, middle click and Ctrl/Cmd+click included. To open links in an editable editor, registeron_link_clickand openclick.link.hrefyourself (for example onclick.primary, the Ctrl/Cmd+click convention). Desktop never navigates: there is no browser to follow the link.
Autocomplete popups: keys, selection and caret geometry
Four handle methods are what an app needs to run a popup off the editor, like a
[[ link picker or an @ mention list. They are the same on desktop and on the
web.
| Method | What it does |
|---|---|
on_key(|key: &EditorKey| -> bool) | Offered every key press before the editor moves the caret, runs a binding or types. true consumes it: the editor does nothing (on the web the keydown is preventDefaulted and stopped). false leaves it to the editor. |
on_selection_change(|sel: &Selection|) | Called after every change that leaves the selection different: typing, deleting, clicks, arrows, commands, app transactions, a load that resets it, a peer’s edit that maps it. Not called when the selection is unchanged. |
on_caret_moved(||) | Called once the runtime has placed the focused editor’s caret somewhere new, after layout. The moment to position the popup. |
caret_rect(pos) -> Option<ElementBounds> | Where a caret at pos is on screen (zero width), whether or not the caret is there: logical window pixels on desktop, client pixels on the web. The frame of a position: fixed popup. |
EditorKey speaks the browser’s KeyboardEvent: key is "ArrowDown",
"Enter", "Escape", "Tab" for named keys and the typed text for printable
ones ("[", "A" with Shift, " " for the space bar); primary is Cmd on macOS
and Ctrl elsewhere, beside ctrl, meta, shift, alt and repeat.
#![allow(unused)]
fn main() {
use rinch_editor_core::Pos;
let picker: Signal<Option<Pos>> = Signal::new(None); // where `[[` began
let popup_at: Signal<(f32, f32)> = Signal::new((0.0, 0.0));
// Open and close from the text before the caret.
editor.on_selection_change({
let editor = editor.clone();
move |sel| picker.set(link_trigger_before(&editor.doc(), sel.head()))
});
// Position once the text is laid out.
editor.on_caret_moved({
let editor = editor.clone();
move || {
if let Some(anchor) = picker.get()
&& let Some(r) = editor.caret_rect(anchor)
{
popup_at.set((r.x, r.y + r.height));
}
}
});
// Drive the list while it is open; every other key stays the editor's.
editor.on_key(move |key| {
if picker.get().is_none() {
return false;
}
match key.key {
"ArrowDown" => list.next(),
"ArrowUp" => list.previous(),
"Enter" | "Tab" => list.accept(), // e.g. replace `[[query` with a link via `update`
"Escape" => picker.set(None),
_ => return false, // typing narrows the list
}
true
});
}
Every callback runs with no internal borrow held, so each may call back into the
handle: read the document, run update(..) or toggle_link(..), move the
selection. A selection move made from on_selection_change calls it again, from
inside itself. Every callback also runs untracked (issue #931): an app effect
that calls set_selection, runs a command or offers a key calls these
synchronously, and the popup’s own signals a callback reads do not become that
effect’s dependencies. Each is one callback per handle (a second registration replaces the
first), like on_change, and costs nothing while none is registered.
Why on_caret_moved and not on_selection_change for geometry. On the web the
browser lays out on demand, so caret_rect is always current. The desktop runtime
lays out once per input event, after the editor has handled it, so from
on_selection_change or on_key a position in the block just edited answers
None (it has no layout yet). on_caret_moved comes from the caret pass after the
layout. The desktop may run a caret pass before an event’s layout as well, so a
call can come with None for the edited block; the one after the layout follows
in the same event. It is not called for a scroll: the caret moves with the editor
then, and a position: fixed popup does not.
What on_key is not offered. A key while an IME composition is in progress (the
input method owns it; the composed text arrives as a commit, which is typing); on
the web, a soft keyboard’s input, which arrives as beforeinput rather than keys
("Unidentified" and "Process" keys are not offered); key releases. A modifier
pressed alone is offered ("Shift", "Control"); ignore it.
With an input method on (on Linux, IBus or Wayland text-input, which the editor
switches on while it has focus), plain printable characters can arrive as input-method
commits rather than as keys, so on_key may never see the [ of a [[ trigger.
Named keys (arrows, Enter, Tab, Escape) still arrive as keys. Detect a typed trigger
from on_selection_change and the document around the caret, which sees the result
however the text came in.
What runs before on_key. Only a menu shortcut (a chord registered by an app menu
or the DOM menu bar). on_key comes before the document-wide
set_keyboard_interceptor and before the dismiss stack — Modal, Drawer and
Popover’s close_on_escape, the DOM menu bar, a <select> popup — on desktop and
in the browser alike. So a popup that claims Escape closes itself and leaves a Modal
around the editor open; an Escape it does not claim goes on and closes the modal.
Callbacks follow their component. on_key, on_selection_change, on_caret_moved
and on_change remember the component that was rendering when they were registered,
and stop being called once it unmounts, even if the EditorHandle lives on (an
app-level handle handed to a view that comes and goes). A live callback runs inside
that component, so a signal it creates belongs to it. A callback registered outside
any render (from main, a timer) keeps app lifetime.
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+Shift+L / E / R / J | Align left / center / right / justify |
| 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 |
| Ctrl+Backspace / Ctrl+Delete (Alt on macOS) | Delete the word backward / forward |
The four alignment chords are the Google Docs ones, and some of them are also
chords of the browser or the platform, which may take the key before the editor
sees it. Chrome reserves Ctrl+Shift+J (and Ctrl+Shift+I) for its developer
tools; Ctrl+Shift+R is the browser’s hard reload; some Linux input methods
(IBus’s emoji picker) bind Ctrl+Shift+E. The commands themselves
(setTextAlignLeft / Center / Right / Justify) always work from a toolbar.
The word-delete chord is the one binding whose modifier differs by platform: on macOS Cmd+Backspace
is the line delete, so the word deletes are Alt(Option)+Backspace / Alt+Delete
there. The desktop binding is chosen at compile time. On the web the keymap binds
no word-delete chord: the browser already turns the platform’s chord into a
beforeinput deleteWordBackward / deleteWordForward, which the web view runs
as the commands of the same name, and leaves Cmd+Backspace to its own line delete.
(Copy/cut/paste — Mod+C/X/V, and Mod+Shift+V for paste-as-plain — are handled by the platform clipboard, not the keymap.) On desktop the same four operations — cut, copy, paste and select all — are also on the built-in right-click menu of the editor (issue #813), and the menu items run exactly the code the chords run; see Right-clicking a text field. On the web the browser’s own menu covers the editor surface. 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.
What a paste does
A paste is the clipboard’s text/html parsed against the schema (unknown tags and
attributes dropped), else its text/plain, one paragraph per line. The content is
then fitted to the caret, as ProseMirror and TipTap do it:
- Text and inline content go in at the caret. Several paragraphs: the first continues the line, the last takes the text after the caret.
- A list pasted on an empty line is that list — bullet, ordered (with its
start) or task (with its checkboxes). Pasted inside a line of text, its first item’s text continues the line, the other items are a list below it, and the text after the caret joins the last item. Pasted in a list item, its items become sibling items of the list the caret is in; on an empty item they take its place. That is between bullet and ordered lists, whose items are the same node (the target list keeps its kind). A task list and a plain list do not mix: one pasted in an item of the other is a list nested in that item. - A heading, a quote or a code block pasted on an empty line is that block; inside text a heading is its text, and a code block is a paragraph for each of its lines (the first continues the line; one copied line of code is just text in the line), never one paragraph holding line ends — wherever the caret is in a line that has content, its start included. A line selected whole counts as empty: the code block takes its place. A selection that runs into another line does not: the code is a line each, and what is left of the last selected line joins the last.
- A rule or a table splits the line: the text before the caret, the block, the text after. At the start or end of a line nothing is split and no empty line is left.
- In a table cell all of this happens inside the cell.
- Over selected table cells the selected cells are emptied and the content goes into the top-left one, as typing over them does. The table’s rows and columns are never changed by a paste.
- In a code block the paste is the clipboard’s plain text, exactly, line breaks included.
- Over a selection the selected content is removed first; a selection of a whole line counts as an empty line.
Copying from inside a list copies the list around the selected items, so the same kind of list arrives wherever it is pasted, in rinch or elsewhere. A selection inside one line copies as its text and marks only.
Lists copied from a browser, GitHub or Google Docs paste as lists, nested ones
included: Docs writes a nested list beside the item it belongs to
(<ul><li>a</li><ul><li>b</li></ul></ul>), and it is read as that item’s. Nothing
in a list is dropped for not being an <li>. A link or other mark around whole
blocks (a card that is one <a> around a heading and a paragraph) is kept on the
text of those blocks. A <b style="font-weight:normal">, which Docs wraps its
copies in, is not bold. All of this is the HTML reader, so load_html and
content: read the same way. (Before #1382 HTML
that began with a <meta> tag, as what Chrome and Firefox copy does, was read as
empty, and the paste fell back to its plain text.)
A paste the document has no valid place for falls back to the plain text; if that has none either, nothing happens. One paste is one undo step.
What the HTML reader keeps
load_html, content: and a paste read HTML the same way, and the reader is
total about text: no markup is refused, the text of every element is in the
document — the elements listed under “Hidden text” below aside — and the result is
always valid content. Structure it cannot keep is approximated; text is not dropped.
It is not a browser: it reads no CSS, so text hidden by display: none, hidden
or a closed <details> is read, and whitespace is kept as written.
-
Lines are where a browser puts them. A block-level element ends the line before it even when it is empty (
a<div></div>bis two paragraphs), and is a line itself when it holds something: a<div>holding only spaces is none, one that holds a non-breaking space is a blank line (and so, unlike in a browser, is one whose space is inside an inline element:<div><span> </span></div>). An inline element that holds a block ends no line by itself:x<b>y<div>z</div>v</b>wisxy,z,vw. -
An element the reader does not know — Word’s
<o:p>and<st1:place>, a custom element,<button>,<label>— is read through. It is inline unless it holds a block, soa <my-chip>b</my-chip> cis one line and<my-card><h2>…</h2><p>…</p></my-card>is its heading and paragraph. (Before #1397 the reader stopped at the first<o:p>, and a paste from Word kept its first paragraph only.) -
Hidden text is not read: comments, conditional comments (
<!--[if gte mso 9]>…<![endif]-->),<style>,<script>,<head>,<title>,<template>. Not read either, though a browser may show something for them: an<input>’s value, the options of a<select>, the fallback content of<object>/<video>/<canvas>, and<svg>and<math>(on a clipboard those are icons and the hidden copy of a rendered formula). An<svg>or<math>that is never closed ends at the first HTML element after it (<p>,<div>,<span>, …), as in a browser, so it does not take the rest of the paste with it; an unclosed<select>,<object>or<video>does, where Chrome shows what follows. An<img>with an emptysrcis no image. -
Broken markup is read as a browser reads it: a
<p>ends at the next block, an<li>at the next<li>, a cell at the next cell; an end tag with nothing to close is skipped, except a</p>, which ends the line as it does in a browser (<p>a<ul>…</ul>c</p>dreadscanddon two lines); a<that starts no tag (a < b) is text. -
A bold, italic, link or code end tag written across a block (
<b>a<div>b</b>c</div>d) ends the formatting where it is written, and what the block already held keeps it:aandbare bold,canddare not (#1410; the bold used to run to the end of the document). As in a browser, such an end tag is ignored when it would reach out of a table cell or across more than seven open blocks. One browser rule is not followed: formatting that a block’s end cut short is not carried on after it, so<p><b>a</p>bhas a boldaand a plainbwhere a browser bolds both (#1445). -
Character references are decoded as a browser decodes them (#1415): numeric ones (
é, Word’s’), every name of the HTML standard (é,+,✓,&), and the older names with no;(© 2024is© 2024,¬it;is¬it;). In a link’s address a name with no;is left alone before=, a letter or a digit, so?a=1©=2stays a query string. Anything else after an&is text (R&D,&nosuch;). -
Table parts with no
<table>are a table: a run of<td>is one row, a run of<tr>the rows of one table (#1392; they used to load as an invalid document and paste as plain text). What has no place in a table — text between its rows, a<caption>— becomes paragraphs in front of the table. That is where a browser shows it for a written<table>; for parts with no<table>a browser has no table to put it in front of, and the order can differ from both the source and the browser (<td>w1<tbody>w2readsw2, thenw1). No text is lost. Parts that do not make full rows (<td>A</td><td>B</td><tr><td>C</td></tr>) are padded with empty cells to a rectangle, as a browser renders them — up to eight empty cells for each cell written (sixteen at least). Parts whose spans ask for more (<td colspan=1000>over a few short rows) are left as written, short rows and all. A<table>is always read as it is written, short rows included. -
A heading around blocks (
<h2><div>…</div><ul>…</ul></h2>) keeps the blocks; the text between them is headings. -
A mark around blocks marks their text and images, not their line breaks, as it does around a line (#1401). Of two links or two colours, one inside the other, the text has the inner one.
-
Nesting is kept 192 elements deep (a list level costs two or three, a quote one). Deeper elements are flattened into the 192nd: every block stays a line of its own with its text, in order; what is lost is the nesting, the marks of inline elements that deep, and the attributes of a block’s later parts when it is split around the blocks inside it (the first part keeps them). Nothing overflows, and reading stays linear in the size of the input. A document nested deeper than that does not read back as itself from its own HTML — the same lines, less nesting.
What the applications people paste from write, and how it reads:
| From | Reads as |
|---|---|
| Word | Headings, paragraphs, tables, links, bold. A Word list is paragraphs that start with their bullet or number (·, 1.): that is how Word writes it, and it is kept that way rather than rebuilt into a list. |
| Excel, Google Sheets | A table (Sheets’ <google-sheets-html-origin> wrapper and Excel’s <col> are read through); one Sheets cell is a line of text. |
| Google Docs | Headings, paragraphs, links, nested lists. Bold, italic and underline are not read: Docs says them with CSS on a <span> (#1407). Text keeps the colour Docs gives it. |
| Notion, GitHub, Slack, Apple Notes and Mail, Gmail | Their headings, lists, quotes, code blocks, tables and links; a <div> per line is a paragraph per line. |
| VS Code | A code block: VS Code copies a <div style="white-space: pre"> holding one <div> per line, and a code block is what keeps the lines and their indentation. The token colours are not kept. Pasted inside a line of text, one copied line is its text, and several lines are a line of text each: the first continues the line, the text after the caret joins the last. |
Whitespace is kept as written, not collapsed as a browser collapses it, so a source that wraps its lines inside a paragraph (Word does) reads with those line ends in the text (#1406).
The rules are Transaction::replace_selection’s; see
Fitting a slice.
Pasting is asynchronous
Reading the clipboard is a request to another application, and that application can be slow or hung — on X11 the read waits up to four seconds. Ctrl+V therefore does not block: the key is consumed immediately, the clipboard is read on a worker thread, and the content is inserted when it arrives (issue #149). The editor stays live throughout, which means the user can keep typing while a slow paste is in flight.
Where does the content land, then? At the position the paste was asked for,
carried across whatever was typed in the meantime. Ctrl+V captures a
SelectionAnchor, every intervening transaction maps that anchor forward through
its steps, and the insertion happens there. Type ahead of it and the paste still
splits the text where you originally pointed; type after it and the paste is
unaffected; move the caret about and nothing happens to it at all — a
selection-only change is not a document change.
If the document is replaced while the read is in flight (load_doc /
load_html) the anchor reports None and the paste is dropped: the content it
was aimed at no longer exists, and reusing the raw offset would drop it into
unrelated text.
A collaborating peer’s edit is not a replacement. The anchor is carried across
it as across a local edit: text a peer types elsewhere shifts it, and text a
peer types in the anchor’s own paragraph leaves it at the same place in the
words. It reports None only when the peer removed, split, joined or changed
the kind of the block it sat in (a paragraph deleted, Enter pressed inside it,
a paragraph made a list item): there the place can no longer be named.
The same anchor is available to your own asynchronous insertions — an uploaded image, a completion from a model:
#![allow(unused)]
fn main() {
let anchor = editor.anchor_selection();
let editor = editor.clone();
fetch_something(move |content| {
// ... back on the UI thread ...
if let Some(sel) = anchor.selection() {
editor.set_selection(sel);
editor.replace_selection_with_text(&content);
}
});
}
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 |
--- / *** | Horizontal rule (fires on the third character, no space) |
> | Blockquote |
- / * / + | Bullet list |
1. | Ordered list |
[ ] / [x] | Task list (unchecked / checked) |
**bold** / __bold__ | bold |
*italic* / _italic_ | italic |
~~strike~~ | |
==highlight== | highlighted |
`code` | inline code |
The horizontal rule fires only when the marker is the paragraph’s whole
content: --- typed in front of existing text, or in a heading, stays text. The
caret lands in the block after the rule, or in a fresh empty paragraph appended
after it (inside the same blockquote or list item) when nothing follows.
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.
Put
data-nofocuson the toolbar. A focusable toolbar button takes the keyboard away from the editor when it is pressed, so Bold would apply to a selection that no longer has focus.data-nofocuson the toolbar container makes every control inside it take its click without taking the keyboard — thepreventDefault()-on-mousedown answer browsers converged on, and it works on both backends. See Taking the click without the keyboard.#![allow(unused)] fn main() { div { data-nofocus: "", class: "toolbar", button { tabindex: "0", onclick: move || ed.command("toggleBold"), "B" } } }
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/CRDT engine — 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.
The editor owns a key only while that textarea holds focus (issue #271). Tab to a
button, press a focusable control (a <button>, a link, a tabindex element, a text
field), or press blank page, and the editor lets go: the next key — Enter and Space
included — belongs to whatever has focus now, and the editor stops painting its
caret, as a blurred editor does on desktop. Press inside the editor to give it the
keyboard back. A switch to another window and back is meant to leave it in place: the
textarea stays the page’s focused element, and the release waits for focus to move
within the page.
A press on a non-focusable clickable — a DOM menu-bar item, a div { onclick }
toolbar button — leaves the keyboard with the editor, as desktop does for such a
press. A focusable one takes it, DropdownMenu’s <button> items included, as on
desktop. A toolbar of real <button>s
that should leave the keyboard with the editor carries data-nofocus (see the
toolbar note above); the editor-web example’s does. A command run while the editor
keeps the keyboard with no pointer event at all — assistive technology or
element.click() on a toolbar button — still moves the caret with its edit.
Right-click gets the browser’s own editing menu (issue #814) — Paste, Cut, Copy,
Select All, and whatever else the browser puts there (emoji, extensions; the hidden
field has spellcheck off, so there are no spelling suggestions). The editor draws no
context menu of its own on the web: a page’s paste needs
navigator.clipboard.readText(), which prompts. Instead, the same hidden textarea
is what the browser’s menu is built for. The editor’s surface is not editable as
far as the browser is concerned, so a right-click there used to open the menu for a
plain element, with no Paste; now a right-button press parks the capture textarea
under the pointer — invisible, but hittable — for the instant the browser needs to
fire contextmenu and hit-test the point for its menu (CodeMirror 5’s technique),
then sends it back off-screen 100 ms later, whether or not a menu came, so a click
on the page after that reaches the page. (A click inside the textarea’s 30 px box
within those 100 ms goes to the textarea instead.) The menu’s items fire the ordinary paste /
cut / copy events on the focused textarea and are answered from the editor’s
model: a paste lands at the editor’s selection, Cut and Copy act on the editor’s
selection even when it spans blocks, and Select All — which the browser can only
apply to the textarea — is detected and becomes the editor’s selectAll, however
long the menu stayed open before it was chosen.
Where the right press lands decides the rest:
- Inside a non-empty selection — on its text, a link or an image in it — the selection is kept and the menu is the editing one, so Cut, Copy and Paste act on the selection.
- On a link or an image outside the selection (or with only a caret) nothing is
parked, so the browser’s own link or image menu opens — Open link, Copy link
address, Save image, Copy image — and there is no Paste at that spot. The caret
moves onto the link, and over an image the selection stays where it was: that is
what a native
contenteditabledoes in Chrome. - On a horizontal rule outside the selection, the rule is selected, and the menu’s Copy and Cut act on it.
- Anywhere else outside the selection, the caret moves to the press point first.
Undo and Redo in the menu act on the editor, but Chrome enables them only when its own page-wide undo stack has a step — after an IME commit in the editor, or after typing into another field on the page — so they are usually greyed out, and Redo stays greyed after a menu Undo. Use Ctrl+Z and Ctrl+Y. (Chrome aims the menu’s Undo at whichever field owns that step; the editor takes it back while it has focus, so the other field is not undone.)
The keyboard’s menu key and Shift+F10 open the same menu at the caret: the browser
sends their contextmenu to the focused element, which is the textarea, and the
editor parks it with its left edge at the caret first — at a selected horizontal
rule, which has no caret, it parks at the rule, and with neither a caret nor a
selected node — Select All in a document that ends with a rule — at the editor’s
own box. On macOS a Control-click opens the menu like a right press, and the word a
Mac right-click selects under the pointer is not mistaken for Select All; neither is
verified on a Mac. An element up the chain carrying a live data-oncontextmenu
still wins, as it does over any other element it wraps: over the whole editor,
links and images included, and for the menu key and Shift+F10, which dispatch that
handler with its click context at the caret and open no browser menu. What was measured, in Chrome 153: a real right press makes Chrome’s own
contextmenu target the parked textarea, unprevented, where before it targeted the
paragraph (outside a selection it still targets the <a> or <img>) — Chrome
builds its menu for the element its hit test finds there, so the editing menu
follows from that. Firefox and Safari are unverified.
Collaboration (optional, collaboration feature)
Two editors can share one live document. Enable the collaboration feature and the
editor projects every local edit onto a yrs (Yjs)
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 a CRDT engine; 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);
}
On desktop, network callbacks usually arrive on 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
}
The transport owns relaying. outbound fires only for an editor’s own local
edits — a delta that arrives through collab_receive is never re-broadcast (it’s
already in the shared CRDT; echoing it back would loop). So the transport must be a
full mesh (every peer’s outbound reaches every other peer directly) or a hub
that fans each delta it receives out to the other peers, forwarding the raw bytes
unchanged. A chain — A wired to B, B wired to C, with nothing joining A and C —
silently partitions: C never sees A’s edits, and nothing errors.
If a peer might have missed deltas — offline, reconnecting, or polling over HTTP with no persistent connection at all — reconcile with a state vector + diff exchange instead of relying on delta delivery being perfect:
#![allow(unused)]
fn main() {
// The peer that might be behind sends its state vector...
let my_sv = editor.collab_state_vector().unwrap();
transport.send(my_sv);
// ...the other side answers with what it's missing, plus its own state vector...
let diff = peer.collab_sync_diff(&my_sv).unwrap();
let peer_sv = peer.collab_state_vector().unwrap();
transport.send((diff, peer_sv));
// ...and the first peer applies it (and can answer the second peer's state vector
// the same way, to reconcile in the other direction too):
editor.collab_receive(&diff);
}
collab_receive is the same entry point for a broadcast delta and a reconciliation
diff — they’re the same wire format, so there’s no separate sync protocol and no
per-peer state to keep on either side; a stateless server can answer each state
vector it’s handed with nothing held between requests.
Never use state-vector equality as a convergence check. A state vector counts insertions only, so a deletion — or a mark removal (yrs un-formats a mark by deleting its format marker) — leaves it unchanged, and two editors can hold different documents behind equal state vectors. Always request and apply a diff instead; the diff reply always carries the full delete set, which is what actually converges the peer.
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/…),
list containers (bullet/ordered lists and list items) and block quotes, nested
into each other to any depth, tables (merged cells included, anything above inside a
cell), horizontal rules, and the inline atoms inside a line — images and hard breaks
(Shift+Enter). Task lists, and a pasted table whose rows are ragged, are still outside
it: 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). Horizontal rules, images, hard breaks, block quotes and tables all
joined that scope without a new format tag, so every peer on a document must be upgraded
together: an older build accepts a rule, an image, a hard break, a quote or a table from a newer
peer and then cannot read it, which poisons its session (see below) in both
directions for as long as that content remains in the document — for a rule, an
image or a hard break it heals when the last one is deleted. A quote may never
leave: one emptied by two people deleting its contents at the same time is no
longer shown, but it stays in the shared document for good (deleting it could race
a third person typing into it), so once that has happened an older build stays
locked out of the document for its whole life. A peer joining from a snapshot that
already holds one fails the join instead.
Wrapping a paragraph in a quote or a list, or lifting it out of one, replaces that paragraph in the shared document, so if someone else is typing in it at that moment their typing is lost; both editors still end up with the same document.
Tables keep a full grid however their structure is edited at once: a row and a column added by two people meet at an empty cell, two columns added at once are both there in every row, and a row deleted while someone types in it is gone with that typing. What can still be lost is typing in a row, column or cell someone else deletes or merges away, and, when two people type at once into the same empty cell that such a concurrent row-and-column insert created, one of the two. Rows, columns and paragraphs that look alike are told apart by which one was actually edited, not by their content: deleting the first of three empty rows while someone types in the last keeps their typing.
A table read from someone else that would be absurdly large for what the shared
document actually holds (rows and columns cost a few bytes each to send, the empty
cells between them nothing) is not built: it shows up as a single empty cell instead,
and while the document holds one, the editor is frozen: it refuses every edit, as
a read-only editor does (typing, commands, paste, undo and load_doc/load_html do
nothing and answer false; the caret, selection and copying still work), while other
people’s edits keep arriving. collab_outbound_stall() reports
CollabError::OversizedTable naming the table; show it — “a collaborator added a
table too large to load; delete it to keep editing”. No edit is allowed near the
table — not even deleting it with the editor — because the empty cell is not the
table, and a change to it (or a document reloaded from an export, which cannot tell
it from an ordinary empty table) could delete the real one for everybody; and none is
kept to send later, because the next change from anyone else would wipe it. Two
things end it: collab_oversized_tables() lists the tables and
collab_delete_oversized_table(id) deletes one from the shared document for every
peer (it works while frozen); or someone else deletes or shrinks it. Nothing typed is
lost: nothing was accepted. One person’s edits never make such a table;
two people adding hundreds of rows and hundreds of columns at the same moment could.
A table you make yourself whose merged cells span far more rows and columns than it has cells (thousands of them) is refused before it is shared, and the session reports it as not syncing until you remove it.
An image’s attributes merge one by one: two people changing different attributes of
one image at the same moment (one its alt, the other its title or an app’s
data-* attribute) both keep their change, and two changing the same attribute end up with one
of the two values. An image is known by its type and its src: changing its src
makes it a new image, and a change someone else makes to the old one at that moment is
dropped. A change to an image’s attributes is kept while someone else types beside it,
presses Enter or Backspace anywhere in its line, drags it, inserts or deletes another
image next to it (an identical one included), copies it elsewhere, makes the line bold
or turns it into a heading; if they delete the image, it is deleted. Such a change
never shows on another image; pasting a picture with the same src over it counts
as the same image, so the change shows on the pasted one. Some cases lose it, and both
editors still end up with the same document when they do: changing the image’s
src, or pasting a picture with another src over it, at that moment (the
editor cannot tell the two apart, and a change dropped is better than a change shown
on the wrong picture); moving the image into or out of a table cell; cutting the
image and pasting it back, or deleting it and undoing the delete (the image comes
back as a new one: only a move made in one step, such as a drag, keeps it);
dragging an image after copying it within the editor, until either copy is changed
(the two are one image to the editor, so which one moved cannot be told); and
loading a document over it. Two people moving one image at the same moment (both
pressing Enter before it) end up with two copies of it.
Every person sharing a document must run a version with per-attribute image merging before any of them changes an image’s attributes or moves an image: an older version refuses such an image and stops syncing until it rejoins on a newer one.
Typing right after a link while someone else changes that link at the same moment
keeps their change — a new href, removing the link, or extending it over the text
after it — but the character you typed may end up inside their link rather than
plain, because it sits exactly where their change starts or ends. The same is true when they link the text right after yours to
something else. Nothing is lost and both editors converge; the typed character is
just formatted where you did not mean it (#923). A link is never brought back over text
someone unlinked, and never spreads over text nobody linked.
A runnable two-pane loopback (both editors in one window, no network)
lives at examples/collab-editor-demo/src/main.rs.
Outbound stalls: an out-of-scope edit, and how it un-sticks. The local edit that was refused is still in your document — only the CRDT declined it — so from that moment your editor holds content collaboration cannot express. Outbound is stalled: that edit and every one after it stays local, while inbound keeps working normally and the shared document stays healthy.
collab_outbound_stall() reports it. Unlike collab_take_error(), which is a
one-shot event you take and clear, this is the state — Some(err) for as long as
the condition holds — so it is what to drive a persistent indicator from:
#![allow(unused)]
fn main() {
if let Some(err) = editor.collab_outbound_stall() {
// e.g. "Not syncing — remove the task list to resume." The error names the
// content: "collab does not support this content yet: node `task_list` …".
show_banner(&err.to_string());
}
}
The cure is to remove the offending content — undo the paste, remove the task list. You do not have to re-send anything: the next edit that projects re-bases on the CRDT and broadcasts everything that accumulated during the stall, in one delta. Nothing typed while stalled is lost (issue #220).
This is not SessionPoisoned and needs no rejoin — see the next paragraph for that.
Errors: transient vs poisoned. Most collaboration errors are transient: an
undecodable blob from the transport, a local edit outside the staged scope, or a
rebuild still waiting on an out-of-order delta’s missing dependency — the session
keeps collaborating, and collab_take_error() tells you what was refused. One
class is not: inbound bytes that, once integrated, leave the shared CRDT document
unprojectable with nothing pending that could cure it (e.g. bytes from a
foreign, non-rinch yrs document, or a peer delta carrying content this build
cannot read back). yrs has no rollback, so such a session cannot receive — and,
before issue #196, it would keep broadcasting while receiving nothing, silently
partitioning the peers. The session now poisons itself instead: sticky
CollabError::SessionPoisoned on every affected call, in both directions
(local edits stop being projected/broadcast, and receives keep failing — though
they are still attempted, so an inbound update that makes the document
rebuildable again clears the poison on its own). A heal re-syncs the editor to
the converged shared document, discarding any local edits made during the
poison window — each was already refused loudly when it happened — the same
semantics as stopping and rejoining.
is_collaboration_poisoned() queries the state; the recovery in practice is
stop_collaboration() followed by rejoining from a healthy peer’s snapshot
(collab_snapshot() → start_collaboration_guest).
A read-only collaborator. set_read_only(true) on a collaborating
editor makes it a live view of a document other people are writing: collab_receive
(and post_remote_delta, and a reconciliation diff) integrates and re-projects as
ever — remote integration does not pass through the gate local edits pass through, so
there is nothing to switch off and on around it — while no local change is recorded
onto the CRDT and outbound never fires. Set it before or after joining, and flip it
mid-session when a role changes. Two consequences worth knowing:
load_html/load_docon a collaborating read-only editor are refused (load_htmlanswersfalse): with a session attached a load is a write to the shared document.stop_collaboration()first, or join the next document withstart_collaboration_guest, which adopts it without writing anything.collab_sync_diffis a pure read and still answers. A read-only client that was editable earlier in the session can hold edits its server lacks; whether to send that diff is the app’s call (a server that enforces the role will refuse it).
Sticky positions (deep links)
A Pos is a number into this editor’s document now: the next edit before it, yours
or a peer’s, makes it point somewhere else. To remember a place in a shared document (a
link to a paragraph, a bookmark, a comment anchor), ask for a sticky index instead.
It follows its character through every later edit, on every replica:
#![allow(unused)]
fn main() {
// Where the caret is, as an address that survives edits.
let anchor: Vec<u8> = editor.collab_sticky_index(editor.state().selection.head()).unwrap();
// Later, on this editor or on any peer's:
if let Some(pos) = editor.collab_resolve_sticky(&anchor) {
editor.set_selection(Selection::cursor(pos));
}
}
What it survives and what it does not:
-
Any edit elsewhere, local or merged from a peer: it moves with its character.
-
Its own character deleted: it resolves to where that character was.
-
Its block deleted:
collab_resolve_stickyanswersNone. Joining a block into the one before it counts (Backspace at a block’s start): the projection writes the joined text as a new insert, not a move. Likewise, splitting a block (Enter) before the position moves the tail into a new block, and an index on the tail then resolves to the split point. -
An edit next to its paragraph, at the top level or inside a list, a quote or a table cell. The projection finds the blocks an edit left alone by node identity, and a split or a join rebuilds both nodes it touches, so the paragraph beside it is rewritten even though its text did not change (tracked in #917). In practice, when the paragraph has a sibling at its level:
- Enter at the start of a paragraph (inserting an empty paragraph above it):
every index into that paragraph now resolves into the new empty paragraph.
That is a wrong position, not
None. - Backspace that deletes an empty line above a paragraph: every index into that
paragraph resolves to
None. - Toggling a bullet list on a paragraph replaces the block with one of another
kind:
None.
A paragraph that is alone at its level (the document’s only block, a list item’s only paragraph) fares better: an edit there keeps nothing of the level, which is then compared by content, so Enter at its start keeps its indexes.
- Enter at the start of a paragraph (inserting an empty paragraph above it):
every index into that paragraph now resolves into the new empty paragraph.
That is a wrong position, not
collab_sticky_index answers None when not collaborating, for a position that is
not inside a textblock (between two blocks), for the empty starter paragraph of a
shared document that has no blocks, and while the session is stalled or poisoned.
The bytes are plain yrs. They are a yrs StickyIndex in its v1 encoding
(StickyIndex::encode_v1), created on the text of the textblock holding the position,
with Assoc::After (or Assoc::Before at the very end of a text; an empty text names
the text itself). Nothing wraps them. So an app that keeps the shared document outside
the editor (a server, an index) can resolve one with yrs alone:
StickyIndex::decode_v1, then get_offset on a transaction of the document, then
check that offset.branch is the text of a block still under the content root.
offset.index counts UTF-16 code units, like every index of this document, but only
if you built your yrs::Doc for it. The offset unit belongs to each Doc and is not
carried in the bytes, and Doc::new() defaults to OffsetKind::Bytes, under which
get_offset gives a number in neither unit once the text has been edited (7 where the
UTF-16 answer is 4, after typing 😀ö before the position). Build the replica the way
the adapter builds its own:
#![allow(unused)]
fn main() {
use yrs::{Doc, OffsetKind, Options};
let doc = Doc::with_options(Options {
offset_kind: OffsetKind::Utf16,
..Default::default()
});
}
The
CollabDoc::sticky_index docs say the same at
the source. The adapter itself exposes the pair as CollabSession::sticky_index(doc, pos) / resolve_sticky(doc, bytes), taking the model document the session projects.
On the web
The same adapter runs in the browser — yrs 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). - No randomness shim needed. yrs carries its own
fastrand/jssource and builds forwasm32-unknown-unknownwith no extra features — unlike Automerge, which needed the app to adduuid = { version = "1", features = ["js"] }just to make the wasm build compile.rinch-web’scollaborationfeature now needs nothing else configured by the app.
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.