Headless, lightweight, framework-agnostic rich text editor engine.
TextifyLib is the engine behind a rich text editor — not a document editor application. It owns the editor's logic (document model, parsing, serialization, history, commands) while the UI is entirely up to you. No toolbar, no CSS, no components, no framework.
┌─────────────────────────────┐
│ Your UI │
│ B I U H1 List Link │
└──────────────┬──────────────┘
▼
┌─────────────────────────────┐
│ TextifyLib Core │
│ Editor / State / Commands │
│ Transactions / History │
│ Schema / Parser / Serializer│
└──────────────┬──────────────┘
▼
contenteditable DOM
npm install textifylibimport { createEditor } from 'textifylib';
const editor = createEditor({
element: document.querySelector('#editor')!,
content: '<p>Hello <strong>world</strong></p>',
});
console.log(editor.getHTML());
// '<p>Hello <strong>world</strong></p>'
console.log(editor.getText());
// 'Hello world'
console.log(editor.getJSON());
// { type: 'doc', content: [ { type: 'paragraph', content: [ ... ] } ] }| Method | Description |
|---|---|
getHTML() |
Serialize the document to a clean HTML string. |
getJSON() |
Return the document model (deep copy). |
getText() |
Return the plain text content. |
setHTML(html) |
Replace the document by parsing an HTML string. |
setJSON(json) |
Replace the document with a JSON tree. |
clear() |
Reset the editor to an empty paragraph. |
focus() |
Focus the editable surface. |
blur() |
Blur the editable surface. |
isFocused() |
Whether the editable surface has focus. |
isEmpty() |
Whether the document contains no text. |
getSelection() |
The current selection as { from, to, empty }. |
setSelection(from, to) |
Set the selection from flat model positions. |
getActiveMarks() |
Marks active at the caret or common to the selection. |
isActive(name, attrs?) |
Whether a mark/node is active at the selection. |
commands.* |
Run formatting/undo commands (see below). |
can().* |
Query whether a command is currently applicable. |
on(event, cb) |
Subscribe to an event (update, transaction, ...). |
off(event, cb) |
Unsubscribe from an event. |
destroy() |
Detach all listeners and release the editor. |
interface EditorOptions {
element: HTMLElement; // required
content?: string; // initial HTML
editable?: boolean; // default true
placeholder?: string; // shown via data-placeholder attribute
extensions?: Extension[]; // nodes, marks, commands, shortcuts, events
}editor.commands.bold(); // toggle bold on the selection
editor.commands.italic();
editor.commands.underline();
editor.commands.strike();
editor.commands.heading(2); // set block to <h2>
editor.commands.paragraph(); // set block to <p>
editor.commands.bulletList(); // wrap selection in <ul>
editor.commands.orderedList(); // wrap selection in <ol>
editor.commands.link({ href: 'https://example.com' });
editor.commands.unlink();
editor.commands.selectAll(); // select the whole document
editor.commands.undo();
editor.commands.redo();
editor.can().bold(); // whether a command is currently applicable
editor.can().undo();Each command operates on the current selection. Commands mutate the document model, re-render, and restore the selection. Mark commands currently require a non-empty selection.
isActive(name, attrs?) reports the active state:
editor.isActive('bold');
editor.isActive('heading', { level: 2 });
editor.isActive('link', { href: 'https://example.com' });The selection layer maps between browser (DOM) positions and flat model
positions (ProseMirror-style integer offsets, where block boundaries occupy a
position). It powers the commands, isActive, and getActiveMarks:
editor.getSelection(); // { from: number, to: number, empty: boolean } | null
editor.setSelection(from, to); // programmatically set the selection
editor.getActiveMarks(); // e.g. [{ type: 'bold' }]editor.on('update', ({ editor }) => console.log(editor.getHTML()));
editor.on('selectionUpdate', ({ selection }) => console.log(selection));
editor.on('transaction', ({ transaction }) => console.log(transaction));
editor.on('focus', () => {});
editor.on('blur', () => {});
editor.off('update', handler);Every document change flows through a single dispatch path that records a
Transaction in the undo/redo history and emits update and transaction.
Undo/redo also emit update. Consecutive keystrokes are grouped into a single
history entry (500 ms threshold).
Extensions register node/mark specs, commands, shortcuts and event handlers:
import { createEditor, toggleMark, type Extension } from 'textifylib';
const HighlightExtension: Extension = {
name: 'highlight',
marks: [{ name: 'highlight', parseDOM: [{ tag: 'mark' }], toDOM: () => ['mark', {}, 0] }],
commands: {
toggleHighlight: (ctx) => {
const sel = ctx.getSelection();
if (!sel || sel.from === sel.to) return false;
const doc = toggleMark(ctx.getDocument(), sel.from, sel.to, { type: 'highlight' });
return ctx.apply(doc, sel.from, sel.to);
},
},
shortcuts: { 'Mod-h': 'toggleHighlight' },
eventHandlers: { update: ({ editor }) => console.log(editor.getHTML()) },
};
const editor = createEditor({
element,
extensions: [HighlightExtension],
});
editor.commands.toggleHighlight();A command receives a CommandContext (getDocument, getSelection, apply,
setSelection, undo, redo, canUndo, canRedo) and can use the exported
pure transforms (toggleMark, setMark, removeMark, setBlockType,
toggleList).
Built-in keyboard shortcuts (Mod is Ctrl on Windows/Linux, Cmd on macOS):
| Combo | Command |
|---|---|
Mod-B |
bold |
Mod-I |
italic |
Mod-U |
underline |
Mod-Z |
undo |
Mod-Shift-Z |
redo |
Extensions add shortcuts via the shortcuts map (combo → command name).
The document model is the single source of truth. It is a tree of nodes:
{
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Hello " },
{ "type": "text", "text": "world", "marks": [{ "type": "bold" }] }
]
}
]
}Supported nodes: doc, paragraph, heading, bulletList, orderedList,
listItem, text.
Supported marks: bold, italic, underline, strike, link.
setHTML() parses HTML into the model; getHTML() serializes the model back to
clean, consistent HTML. Unknown elements are unwrapped (their inline text is
kept) and dangerous elements (script, iframe, ...) are dropped.
Pasting is intercepted and processed as Clipboard → sanitize → parse → model → transaction → insert, so arbitrary HTML is never inserted directly:
text/htmlis sanitized (dangerous elements, event handlers and unsafejavascript:/data:URLs are removed), parsed, and inserted at the selection.text/plainis inserted inline (single line) or as paragraphs (multi-line).- Pasted content is recorded in the undo history.
The Sanitizer is configurable (blockedTags, allowedSchemes,
urlAttributes) and can be subclassed for stricter or custom rules.
The core is divided into small, single-responsibility modules:
model/— the document model (Node,Mark), independent of the DOM.schema/— node and mark specs (name, group,toDOM,parse).serialization/—HTMLParser,HTMLSerializer, plain-text extraction.dom/—DOMRendererprojects the model into the contenteditable element.selection/— DOM ↔ model position mapping.core/— theEditor,Transaction,History, and thecreateEditor()factory.
Data flows in two directions, always through the model:
Document Model ──DOMRenderer──▶ contenteditable (render)
DOM events ──Editor──▶ Transaction ──▶ Document Model (dispatch)
The DOM is never the source of truth. All document changes flow through a single
dispatch path: a pure transform produces the next document, which is recorded
as a Transaction in the history, rendered (for commands), and emitted to
listeners. Keystrokes are captured by re-parsing the DOM into the model.
The selection layer maps between DOM positions and flat model positions (ProseMirror-style integer offsets). Commands read the current selection, mutate the model through pure transforms, re-render, then restore the selection from the (unchanged) flat positions.
The current milestones intentionally defer a few things, documented so future work can replace them cleanly:
- Input sync re-parses the whole DOM. Later milestones will replace this
with incremental, step-based transactions (
insert/delete/addMark). - History stores whole document states. Documents are immutable, so states are shared by reference (no copying), but a step-based history with invertible steps will reduce memory further.
- Collapsed-selection marks are no-ops. Applying a mark with no text selected does nothing; "stored marks" (marks applied to the next typed character) land with the transaction/input pipeline.
- Milestone 1 — Core ✅ TypeScript, editor, contenteditable, document model,
paragraph/text, HTML parser & serializer,
getHTML/setHTML/getText. - Milestone 2 — Formatting ✅ bold, italic, underline, strike, headings,
links,
commands/can()/isActive, selection layer. - Milestone 3 — Selection ✅ public
getSelection/setSelection,selectAll,getActiveMarks, DOM ↔ model position mapping. - Milestone 4 — History ✅ transactions, undo, redo, events.
- Milestone 5 — Lists ✅ bullet list, ordered list, list item.
- Milestone 6 — Extensions ✅ extension manager, custom commands/marks/nodes, shortcuts.
- Milestone 7 — Paste ✅ plain text, HTML, sanitization.
- Milestone 8 — Polish ✅ tests, coverage, performance, docs, npm package.
npm install
npm run test # run tests (Vitest)
npm run test:coverage # run tests with coverage (thresholds enforced)
npm run typecheck # TypeScript strict check
npm run lint # ESLint
npm run build # typecheck + build the library
npm run dev # start the demo (vanilla JS/TS)
npm run bundle:size # build and report gzip sizeBundle size: core gzip < 20 kB (currently ~9.5 kB). Test coverage is measured with thresholds (90% lines/functions/statements, 70% branches).
See ARCHITECTURE.md for the design decisions and rationale.
MIT