Elements
RichEditor
RichEditor is a rich text editor built after ProseMirror’s architecture. The app defines what a document is made
of with a schema (paragraphs, headings, lists, mentions, whatever node and mark types it needs), and the editor keeps
every document valid against it. Changes are steps grouped into transactions, so they undo, map positions and travel
as JSON; behavior comes from plugins (history, keymaps, input rules, Markdown shortcuts, the app’s own); commands are
values a toolbar runs and asks about. The editor’s state, its commands and its plugins run in Rust: typing, key
bindings and input rules never wait for JS, and the app reads the state as JSON when it asks for it.
import { mount, signal, Text, View } from "nsh";
import { RichEditor, StarterKit, command, createRichEditor, redo, undo } from "nsh/base";
import { button, buttonText, desktop, Log, Panel, Split, ui, wide } from "./palette";
const CONTENT = `# A document
Type **Markdown** as you go: \`# \` makes a heading, \`- \` a list, \`[ ] \` a task.
- Select text and press Bold
- Undo and redo keep every step
`;
// The session owns the document; the view renders it and turns input into
// transactions. The toolbar runs Tiptap's named commands on the session.
function App() {
const editor = createRichEditor({ extensions: [StarterKit], content: CONTENT });
const markdown = signal(editor.getMarkdown());
const events = signal<string[]>([]);
const log = (line: string) => events.set([line, ...events()].slice(0, 4));
editor.subscribe(() => markdown.set(editor.getMarkdown()));
editor.on("focus", () => log("focus"));
editor.on("blur", () => log("blur"));
const action = (label: string, run: () => void) => (
<View onPress={run} role="button" accessibilityLabel={label} {...button("secondary", { size: "sm" })}>
<Text style={buttonText()}>{label}</Text>
</View>
);
return (
<View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>rich editor</Text>
<Text style={{ color: ui().muted }}>
{desktop ? "Click into the document and type; Cmd/Ctrl+B makes bold" : "Tap into the document and type"}
</Text>
<Split
grow
width={640}
inspector={
<>
<Panel title="markdown">
<Text style={{ fontSize: 12, fontFamily: "monospace", color: ui().text }}>{markdown()}</Text>
</Panel>
<Log title="events" lines={events()} minHeight={72} />
</>
}
>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
{action("Bold", () => editor.run(command("toggleBold")))}
{action("Italic", () => editor.run(command("toggleItalic")))}
{action("Heading", () => editor.run(command("toggleHeading", { level: 2 })))}
{action("List", () => editor.run(command("toggleBulletList")))}
{action("Task", () => editor.run(command("toggleTaskList")))}
{action("Undo", () => editor.run(undo))}
{action("Redo", () => editor.run(redo))}
</View>
<View style={{ height: wide() ? 360 : 300, ...ui().card, borderRadius: 14, overflow: "hidden" }}>
<RichEditor
editor={editor}
accessibilityLabel="Document"
placeholder="Write something"
classes={{ content: "p-4", taskCheckbox: "size-4 mr-2 mt-1 rounded border border-border" }}
style={{ flex: 1 }}
/>
</View>
</Split>
</View>
);
}
mount(App);An editor has two parts: the session (createRichEditor), which owns the state and which the app’s code drives, and
the view (<RichEditor editor={…} />), which renders the session natively and turns the platform’s input into
transactions. Several views can show one session.
import { mount } from "nsh";
import { RichEditor, createRichEditor, history, inputRules, keymap, markdown, markdownRules, starterKeymap, baseKeymap, toggleMark } from "nsh/base";
const editor = createRichEditor({
content: "# Notes\n\nStart typing…",
format: "markdown",
plugins: [
history(),
keymap({ "Mod-b": toggleMark("bold"), "Mod-i": toggleMark("italic") }),
starterKeymap(),
baseKeymap(),
inputRules(markdownRules()),
markdown(),
],
});
function App() {
return <RichEditor editor={editor} class="flex-1" />;
}
mount(App);
Without schema an editor uses the starter schema (starterSchema()); without plugins it gets the starter kit
(starterKit(): the starter’s input rules and key bindings, the base key bindings and the history). An empty
plugins array makes an editor with no plugins at all.
#Styling
The editor draws the document with the app’s look: classes and styles style its parts (root, content, caret,
selection, placeholder, composition, selectedNode, listMarker), its nodes by type (nodes: { heading: "text-2xl font-bold", "heading[level=2]": "text-xl" }) and its marks (marks: { bold: "font-bold" }). The items of
lists whose node spec sets extra.listMarker to "bullet" or "ordered" (the starter schema’s lists) show a marker
in the listMarker part: •, ◦, ▪ by depth, or numbers from the list’s start, placed before the item, in the list’s indent (pl-6 on the list, as a browser indents ul).
<RichEditor
editor={editor}
classes={{
nodes: { heading: "text-2xl font-bold", bulletList: "pl-6", codeBlock: "font-mono bg-muted p-3 rounded-md" },
marks: { bold: "font-bold", italic: "italic", code: "font-mono bg-muted" },
listMarker: "text-muted-foreground",
}}
/>
#Schemas
defineSchema(spec) reads a schema in ProseMirror’s shape and throws with what is wrong in it. Node types name their
content (a content expression: "inline*", "paragraph+", "(heading | paragraph) block*"), their group,
whether they are inline or an atom, the marks they allow and their attrs with defaults and validation
("string", "number", "boolean"). The first block type is the default one: what Enter creates and what fills
required content. Keys a spec does not know are kept as the type’s extra data, for frameworks built on the editor.
import { createRichEditor, defineSchema } from "nsh/base";
const schema = defineSchema({
nodes: {
doc: { content: "block+" },
paragraph: { content: "inline*", group: "block" },
mention: { group: "inline", inline: true, atom: true, attrs: { id: {}, label: { default: "" } } },
text: { group: "inline" },
},
marks: { bold: {}, link: { attrs: { href: {} }, inclusive: false } },
});
const editor = createRichEditor({ schema, doc: schema.parse("Hello") });
A RichSchema reads and writes documents: parse(content, format?) (plain text by default, "markdown", or JSON),
parseMarkdown(markdown), serialize(doc, format?) (Markdown by default, "text", "json") and check(doc), which
returns the document with its attribute defaults filled in. nodes, marks and topNode name its types. Markdown
maps to the starter schema’s names (paragraph, heading, blockquote, codeBlock, bulletList,
orderedList, listItem, bold, italic, code, link, strike).
#Commands
A command is a value: toggleMark("bold") makes an object the app keeps, binds to keys and passes to the editor.
editor.run(command) runs it and returns whether it applied; editor.can(command) asks without changing anything,
which is how a toolbar enables its buttons. editor.isActive(name, attrs?) tells whether a mark or a node is active
at the selection (isActive("heading", { level: 2 })).
| Command | What it does |
|---|---|
toggleMark(name, attrs?) | Adds the mark to the selection, or removes it where present; at a cursor, for the text typed next |
setMark(name, attrs?) / unsetMark(name) | Adds or removes a mark |
setBlockType(name, attrs?) | Makes the selected textblocks of a type (setBlockType("heading", { level: 2 })) |
wrapIn(name, attrs?) / lift() | Wraps the selected blocks in a node (a quote) / lifts them out of their parent |
wrapInList(name, attrs?) | Wraps the selected blocks in a list |
splitListItem(name) / sinkListItem(name) / liftListItem(name) | Splits, indents or outdents list items |
undo() / redo() | Undoes or redoes a change (with history()) |
selectAll(), setTextSelection(anchor, head?), setNodeSelection(pos) | Selects |
insertText(text), insertContent(content, format?) | Inserts text or content over the selection |
setNodeAttribute(pos, attr, value) | Sets an attribute of the node at a position, as one undoable change |
command(name) | A command without parameters by name: "splitBlock", "joinBackward", "exitCode", "newlineInCode", "selectParentNode", … |
chain(commands) | Runs commands one after another as one transaction (one undo step); applies only when every one does |
first(commands) | Runs the first command that applies |
editor.run(chain([setBlockType("heading", { level: 1 }), toggleMark("bold")]));
const canIndent = editor.can(sinkListItem("listItem"));
#The session
| Member | What it is |
|---|---|
state, doc, selection | The state, the document and the selection as JSON, made when read |
revision | How many transactions the state has applied |
isEmpty, undoDepth, redoDepth, composing | What a UI shows about the editor |
setContent(content, format?) | Replaces the document, as one undoable change |
insertContent(content, format?) | Inserts content over the selection |
getJSON(), getMarkdown(), getText(), textBetween(from?, to?, separator?) | The document in a format |
dispatch(tr) | Applies a transaction given as JSON (its steps against the current document) |
subscribe(listener) / unsubscribe(id) | Hears every change, from any source, with the new revision |
focus(), blur() | Moves the keyboard focus to or from the views showing the editor |
Positions count as ProseMirror’s do: a text counts its UTF-16 code units, a leaf node one, a node with content its two boundaries. Anything that must outlive edits keeps an id in a node’s attributes or a mark.
#Taking over transactions
editor.dispatchTransaction = (tr) => { … } makes the app commit every transaction itself: the handler gets each
one as JSON and commits it with editor.applyTransaction(tr), or drops it. Collaboration, logging and transactions
that must pass the app’s checks use it; null hands committing back to the editor. The handler hears typing in JS’s
next turn, and the document changes when it commits.
#Extensions
Extensions are Tiptap’s: each one brings, together, its node or mark type, its key bindings, input and paste rules, plugins and named commands, and an editor made from extensions gets its schema, keymap, rules and command table from them. The built-in ones are classes named as Tiptap’s, used as they are or configured:
import { RichEditor, StarterKit, Underline, command, createRichEditor } from "nsh/base";
const editor = createRichEditor({
extensions: [StarterKit.configure({ heading: { levels: [1, 2, 3] }, codeBlock: false })],
content: "Hello",
editable: true,
});
editor.run(command("toggleHeading", { level: 2 }));
editor.run(command("toggleBold"));
StarterKit brings Document, Paragraph, the text node, Heading, Blockquote, BulletList, OrderedList,
ListItem, TaskList, TaskItem, CodeBlock, HardBreak, HorizontalRule, RichImage (Tiptap’s Image),
Bold, Italic, Strike, Code, Underline, Link, Highlight, Subscript, Superscript, TextStyle,
TextAlign, History, TrailingNode, Typography and ListKeymap. An option named as one set to false leaves
it out, an object configures it (heading: { levels }, history: { depth }, textAlign: { types },
codeBlock: { enableTabIndentation }). Each class’s configure(options) and extend({...}) make a
RichExtension, which has name, type, priority and options. Every editor also gets the core keymap (Enter,
Backspace, Delete, select all).
Each extension adds Tiptap’s commands, which command(name, args) runs: toggleBold, setItalic,
unsetStrike, toggleHeading({ level }), setParagraph, toggleBlockquote, toggleBulletList,
toggleOrderedList, toggleTaskList, toggleCodeBlock({ language }), setHardBreak, setHorizontalRule,
setImage({ src }), toggleHighlight({ color }), setTextAlign("center"), unsetTextAlign, and the link,
character count and suggestion commands below.
The order of extensions is their priority among equals: an extension listed later binds its keys before the ones
before it; priority (100 by default) ranks them otherwise. With extensions, an editor’s plugins come after the
extensions’ plugins.
#The app’s extensions
defineExtension makes one. Its functions run once, when an editor is made with it, with the extension’s options,
and return data: commands, rules and plugins as the factories above make them.
import { StarterKit, command, createRichEditor, defineExtension, insertText, textRule } from "nsh/base";
const Signature = defineExtension({
name: "signature",
priority: 200,
addOptions: () => ({ text: "— Sent from NSH" }),
addCommands: (options) => ({ insertSignature: insertText(options.text) }),
addKeyboardShortcuts: () => ({ "Mod-Alt-s": command("insertSignature") }),
addInputRules: () => [textRule("\\(sig\\)$", "✍")],
});
const editor = createRichEditor({ extensions: [StarterKit, Signature.configure({ text: "Best, Ana" })] });
node or mark (in defineSchema’s form) makes it a node or mark extension; addGlobalAttributes adds attributes
to other types; addProseMirrorPlugins returns plugins, RichEditorPlugins included; addPasteRules returns
textRule and markRule rules for pasted text. A built-in class’s extend({...}) replaces the parts it gives:
Underline.extend({ addKeyboardShortcuts: () => ({ "Mod-Alt-u": toggleMark("underline") }) }).
An app’s crate registers extensions written in Rust through its UI set, and JS takes them by name:
use nsh_base::rich_editor::extensions::{Extension, RichExtensionRegistry};
#[native_module("app/native")]
fn native(registry: &mut Registry) {
registry.rich_extension("mention", || Extension::new("mention") /* .add_plugins(...) */);
}
const editor = createRichEditor({ extensions: [StarterKit, richExtension("mention", { char: "@" })] });
richExtension(name, options) also finds every built-in extension by its name ("text", "bold", …).
#Plugins
| Factory | What it adds |
|---|---|
history({ depth?, newGroupDelay? }) | Undo history; changes within newGroupDelay milliseconds (500) undo together |
keymap(bindings) | Commands by key name: "Mod-b" (Cmd on Apple platforms, Ctrl elsewhere), "Shift-Enter", "Alt-ArrowUp" |
baseKeymap() | Enter, Backspace, Delete, select all and the platform’s text editing keys |
starterKeymap() | The starter schema’s bindings: marks, headings, lists, code blocks, hard breaks |
inputRules(rules) | Text typed before the cursor that matches a rule turns into what the rule makes; Backspace right after undoes it |
markdown(options?) | Markdown as you type, Markdown paste and Markdown copy (blockRules, inlineRules, paste, copyAsMarkdown) |
starterKit() | The starter’s input rules and bindings, the base bindings and the history |
A plugin’s place in the list is its priority: keymaps and input rules earlier in the list handle a key or a text
first. Input rules come from markdownRules() (headings, quotes, lists, code fences, rules, the common marks),
typographyRules() (smart quotes, ellipsis, em dash), textRule(match, replacement), markRule(match, mark),
wrappingRule(match, node) and textblockRule(match, node). A rule’s match is a regular expression ending in $,
matched against the text before the cursor.
#The app’s plugins
new RichEditorPlugin(key) makes a plugin whose behavior is the app’s, set with chained calls:
import { RichEditorPlugin, createRichEditor, starterKit } from "nsh/base";
const suggestions = new RichEditorPlugin("suggestions")
.captureKeys(["ArrowUp", "ArrowDown", "Enter", "Escape"], (key) => moveSuggestion(key))
.filterTransaction((tr) => tr.meta?.locked !== true)
.appendTransaction((trs, state) => null);
const editor = createRichEditor({ plugins: [starterKit(), suggestions] });
// While the suggestion list is open, its keys are the list's.
suggestions.capturing = true;
editor.setDecorations("suggestions", [{ from: 5, to: 9, kind: "inline", attrs: { class: "bg-accent" } }]);
captureKeys(keys, handler): whilecapturingis true the editor consumes these keys and tellshandlerwhich one was pressed, right after; the keyboard stays as quick as with no plugin.editor.setDecorations(key, decorations): inline attributes, node attributes and widgets the view draws over the document without changing it. They follow every change until set again.filterTransaction(hook)andappendTransaction(hook)see the transactions the app’s own calls make (run,dispatch,setContent, …) in that call:filterTransactionmay drop one,appendTransactionmay return one more to apply with it.subscribehears every change, typing included.
#Links, character counts and suggestions
Three plugins carry Tiptap’s Link, CharacterCount and Suggestion behavior, all of it native:
import { RichEditor, characterCount, createRichEditor, insertMention, link, starterKit, starterSchema, suggestion, withMention } from "nsh/base";
const editor = createRichEditor({
schema: withMention(starterSchema()),
plugins: [
starterKit(),
link({ defaultProtocol: "https" }),
characterCount({ limit: 280 }),
suggestion({ key: "mention", char: "@", decorationClass: "text-primary", captureKeys: ["ArrowUp", "ArrowDown", "Enter"] }),
],
});
editor.on("transaction", () => {
const s = editor.suggestion("mention");
if (s?.active) showPeople(s.query, editor.coordsAtPos(s.range.from), s.lastKey);
setCount(`${editor.characters()} / ${editor.characterLimit}`);
});
// The list picked someone:
editor.run(insertMention(editor.suggestion("mention")!.range, "u1", "Joan"));
link(options?): a URL typed before a space or Enter becomes a link, as do the URLs in pasted text (autolink); a URL pasted over a selection links the selection (linkOnPaste); a press on a link with Mod held, or while the editor is read-only, opens it through the app’s URL handling (openOnClick);enableClickSelectionselects the whole link on a press. URLs are found with or without a protocol (nsh.dev,https://nsh.dev/docs, e-mail addresses), and the protocol a bare host gets isdefaultProtocol(http). Links take the protocolshttp,https,ftp,ftps,mailto,tel,callto,sms,cid,xmppand those inprotocols, sosetLink({ href }),toggleLink(attrs?)and autolinking keep scripts (javascript:) and data URLs out.unsetLink()removes the link around the selection.characterCount(options?):editor.characters(mode?)counts the text’s characters ("textSize", UTF-16 units as JS’slength) or the document’s positions ("nodeSize");editor.words()counts its words (Unicode word boundaries). Withlimit, changes that would go past it are refused and pasted content is trimmed to what fits; a first document over the limit is trimmed (autoTrim).setCharacterLimit(limit)changes the limit andeditor.characterLimitreads it.suggestion(options?): follows a trigger (char,@by default) typed before the cursor.allowSpaceslets the query hold spaces,allowedPrefixeslists what may precede the trigger (a space by default;anyPrefixtakes anything),startOfLineasks for it at the line’s start (a/command menu).editor.suggestion(key)gives{ active, range, query, text, lastKey, keyCount }; the active range carries the app’sdecorationClass(anddecorationEmptyClasswhile the query is empty). While a suggestion is active the keys incaptureKeysbelong to the app’s list: the editor takes them and records the last one in the state, and Escape dismisses the suggestion (exitSuggestion(key)does too).replaceSuggestion(content, key?)replaces the trigger and the query with text or JSON content.withMention(schema, { name?, char? })adds Tiptap’s mention node: an inline atom withid,labeland its trigger, written as@labelin text and as<span data-type="mention">in HTML.insertMention(range, id, label?)replaces a suggestion’s range with a mention and a space.
The popup is the app’s: it reads the suggestion’s state, places itself at coordsAtPos(range.from) and draws the
items its own way.