A modular, lightweight WYSIWYG rich text editor built with vanilla JavaScript. Zero dependencies.
Maintained on best-effort basis. Issues welcome but not guaranteed to be addressed.
- Block formats: paragraph, headings (H2, H3), preformatted code, blockquote, ordered/unordered lists
- Inline formats: bold, italic, subscript, superscript, hyperlinks
- Image support: inline images and figure with figcaption
- Undo/redo: configurable history with keyboard shortcuts (Ctrl+Z / Ctrl+Shift+Z)
- Clipboard: plain-text paste with automatic paragraph splitting
- Auto-save: adapter-pattern save with page unload prevention
- Customizable toolbar: configurable button groups with overflow scroll
- Dark mode: toggle via
.dark-modeCSS class - i18n ready: auto-detects from
<html lang>, falls back to English
npm install yazmanimport Yazman from 'yazman';unpkg
<link rel="stylesheet" href="https://unpkg.com/yazman/dist/yazman.min.css">
<script type="module">
import Yazman from 'https://unpkg.com/yazman/dist/yazman.esm.js';
const editor = new Yazman(document.getElementById('editor'));
</script>jsDelivr (npm)
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/yazman/dist/yazman.min.css">
<script type="module">
import Yazman from 'https://cdn.jsdelivr.net/npm/yazman/dist/yazman.esm.js';
const editor = new Yazman(document.getElementById('editor'));
</script>jsDelivr (GitHub — works without npm publish)
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/edukah/yazman/dist/yazman.min.css">
<script type="module">
import Yazman from 'https://cdn.jsdelivr.net/gh/edukah/yazman/dist/yazman.esm.js';
const editor = new Yazman(document.getElementById('editor'));
</script>@use 'pkg:yazman';The
pkg:protocol requires Dart Sass 1.71+ andNodePackageImporter. In webpack, add it to your sass-loader options:// webpack.config.js { loader: 'sass-loader', options: { sassOptions: { importers: [new require('sass').NodePackageImporter()] } } }
<!-- Or via link tag -->
<link rel="stylesheet" href="dist/yazman.min.css"><div id="editor"></div>
<script>
const editor = new Yazman(document.getElementById('editor'), {
placeholder: 'Start typing...'
});
</script>const editor = new Yazman(container, options);| Option | Type | Default | Description |
|---|---|---|---|
languageCode |
string |
'auto' |
Language code ('auto' detects from <html lang>, falls back to 'en') |
placeholder |
string |
'' |
Placeholder text shown when editor is empty |
toolbar |
Array |
See below | Toolbar button configuration |
history |
Object |
See below | Undo/redo settings |
autosave |
Object |
See below | Auto-save settings |
ImageUploader |
Class |
null |
Custom image uploader class for figure images |
onError |
Function |
null |
Error callback: (error, context) => {} |
Yazman provides a centralized error handling mechanism via the onError callback. All recoverable errors in public API methods (format, insertNode, deleteContent, etc.) and the internal MutationObserver are routed through this callback.
const editor = new Yazman(document.getElementById('editor'), {
placeholder: 'Start typing...',
onError: (error, context) => {
// context: { module, operation, ... }
console.log(error.message, context);
}
});If onError is not provided, errors are logged to console.error by default. If the callback itself throws, the editor remains stable.
Error context structure:
| Field | Type | Description |
|---|---|---|
module |
string |
Source module ('editor', 'observer', 'toolbar', 'emitter', 'plugin') |
operation |
string |
Operation that failed ('format', 'insertNode', 'init', 'callback', etc.) |
Unrecoverable errors (invalid constructor arguments) throw immediately:
// Throws: 'Yazman: "container" parameter must be a valid DOM element.'
new Yazman(null);The toolbar is defined as an array of button groups. Each group is an array of format names.
toolbar: [
['bold', 'italic'],
['headerTwo', 'headerThree'],
['preformatted', 'blockquote'],
['subscript', 'supscript'],
['hyperlink'],
['figureImage'],
[{ listItem: 'ordered' }, { listItem: 'unordered' }]
]Available format names:
| Name | Format | HTML Tag |
|---|---|---|
bold |
Bold text | <strong> |
italic |
Italic text | <em> |
subscript |
Subscript text | <sub> |
supscript |
Superscript text | <sup> |
hyperlink |
Link (opens URL dialog) | <a> |
headerTwo |
Heading level 2 | <h2> |
headerThree |
Heading level 3 | <h3> |
preformatted |
Code block | <pre> |
blockquote |
Block quote | <blockquote> |
figureImage |
Image with caption | <figure> |
{ listItem: 'ordered' } |
Ordered list | <ol><li> |
{ listItem: 'unordered' } |
Unordered list | <ul><li> |
history: {
counterTiming: 2000, // Save snapshot after this many ms of inactivity (default: 2000)
saveCoefficient: 6 // Save snapshot after this many consecutive edits (default: 6)
}Keyboard shortcuts: Ctrl+Z (undo), Ctrl+Shift+Z (redo).
autosave: {
enable: true, // Enable auto-save (default: false)
counterTiming: 5000, // Save after this many ms of inactivity (default: 36000)
saveCoefficient: 3, // Save after this many consecutive edits (default: 40)
preventUnload: true, // Warn user before leaving page with unsaved changes (default: false)
adaptor: function () { // Save callback - called when auto-save triggers
const content = editor.paper.exportContent();
// send content to server
}
}Autosave methods:
| Method | Description |
|---|---|
editor.autosave.setBlock(true) |
Temporarily block auto-save |
editor.autosave.setBlock(false) |
Resume auto-save |
editor.autosave.setGlobalUnLoad(false) |
Disable page unload warning |
Apply formatting to a range of text.
// Apply bold to characters 0-10
editor.format(0, 10, { bold: true });
// Remove bold
editor.format(0, 10, { bold: false });
// Apply hyperlink
editor.format(5, 15, { hyperlink: 'https://example.com' });
// Change block format to heading
editor.format(0, 10, { headerTwo: true });Apply block-level formatting only (headings, blockquote, preformatted, lists). Does not affect inline formats.
editor.formatLine(0, 50, { preformatted: true });Apply inline formatting only (bold, italic, hyperlink, etc.). Does not affect block formats.
editor.formatText(0, 10, { bold: true, italic: true });Insert content at the specified character index.
// Insert text at position 5
editor.insertNode({ textContent: 'Hello world', format: {} }, 5);
// Insert text with formatting
editor.insertNode({ textContent: 'Bold text', format: { bold: true } }, 10);
// Insert a new paragraph (block format)
editor.insertNode({ textContent: 'New paragraph', format: { paragraph: true }, generateBlock: true }, 20);
// Insert at the end
editor.insertNode({ textContent: 'End text', format: {} }, -1);Delete content in the specified range.
editor.deleteContent(5, 15);Export editor content as a structured array. Each element represents a line with its format and children.
const content = editor.getContent();
// Returns:
// [
// {
// format: { paragraph: true },
// children: [
// { textContent: 'Hello ', format: {} },
// { textContent: 'world', format: { bold: true } }
// ]
// },
// ...
// ]
// Export a specific range
const partial = editor.getContent(0, 50);Import content from a structured array (same format as getContent).
editor.setContent([
{
format: { paragraph: true },
children: [
{ textContent: 'Imported content', format: {} }
]
}
]);Get the plain text content of the editor (no formatting).
const text = editor.getText();Get the total character length of the editor content.
const length = editor.getLength();Get the active formats in a range.
const format = editor.paper.getFormat(0, 10);
// Returns: { paragraph: true, bold: true }Get the line (block-level format instance) at a character index.
const line = editor.paper.getLine(5);
// line.format → { paragraph: true }
// line.start → 0
// line.end → 25Get all lines in a range.
const lines = editor.paper.getLines(0, 100);Get the inline node (child) at a character index.
const node = editor.paper.getNode(5);
// node.textContent → 'Hello'
// node.format → { bold: true }
// node.start → 0
// node.end → 5Get all inline nodes in a range.
const nodes = editor.paper.getNodes(0, 50);Get the current caret position as [start, end]. When collapsed (no selection), both values are equal.
const [start, end] = editor.selection.getCaretPosition();Set the caret position or selection range.
// Place caret at position 10
editor.selection.setCaretPosition([10, 10]);
// Select characters 5 to 15
editor.selection.setCaretPosition([5, 15]);Check if the current selection is collapsed (no text selected).
if (editor.selection.isCollapsed()) {
// cursor is at a single point
}Manually trigger editor state update. Regenerates the Paper model, updates the toolbar, and repositions the caret. Called automatically after most operations.
editor.update();Check if the editor is empty. Optionally show a warning message.
// Check without warning
if (editor.isEmpty(false)) {
console.log('Editor is empty');
}
// Check with warning (default behavior)
if (editor.isEmpty()) {
// warning message is shown on the editor
}
// Check with custom warning message
editor.isEmpty(true, 'Please enter content');Get or set the saved state of the editor.
if (!editor.isSaved) {
// editor has unsaved changes
}
editor.isSaved = true;Focus the editor. By default, prevents scroll jump.
editor.focus();
// Focus and allow scroll to editor
editor.focus(false);Check if the editor currently has focus.
if (editor.hasFocus()) {
// editor is focused
}Scroll the editor viewport so the current caret position is visible.
editor.scrollIntoView();Enable editing (set contenteditable to true).
editor.enable();Disable editing (set contenteditable to false). The editor becomes read-only.
editor.disable();Destroy the editor instance. Disconnects the MutationObserver, removes all event listeners, destroys plugins, clears timers (autosave, history), and removes the editor DOM from the container.
editor.destroy();Show a status message at the bottom of the editor. Disappears after the specified duration.
// Show text status
editor.status('Saved!', 3000);
// Show DOM element as status
const el = document.createElement('span');
el.textContent = 'Custom status';
editor.status(el, 5000);Open a modal dialog inside the editor container.
const form = document.createElement('div');
form.innerHTML = '<p>Modal content</p>';
editor.dialog.insertModal(form, { backcloth: true });Close the currently open modal.
editor.dialog.closeModal();Listen to editor events. Returns the editor instance for chaining.
editor.on('text-change', () => {
console.log('Content changed');
});
editor.on('selection-change', ({ start, end }) => {
console.log('Caret position:', start, end);
});
editor.on('focus', () => console.log('Editor focused'));
editor.on('blur', () => console.log('Editor blurred'));Available events:
| Event | Payload | Description |
|---|---|---|
text-change |
— | Content was modified (typing, paste, etc.) |
selection-change |
{ start, end } |
Caret position or selection range changed |
focus |
— | Editor received focus |
blur |
— | Editor lost focus |
Remove an event listener. If no handler is provided, removes all listeners for that event.
const onChange = () => console.log('changed');
editor.on('text-change', onChange);
editor.off('text-change', onChange);Register a custom format or module.
Yazman.register('format/strikethrough', StrikethroughClass);Register a format set (group of mutually exclusive formats).
Yazman.addFormatSet(['headerTwo', 'headerThree', 'preformatted', 'blockquote']);Register a plugin. The function receives the editor instance and may return an object with a destroy() method for cleanup.
Yazman.plugin('word-count', (editor) => {
const counter = document.createElement('span');
const onChange = () => {
counter.textContent = editor.getLength() + ' chars';
editor.status(counter, 10000);
};
editor.on('text-change', onChange);
return {
destroy () {
editor.off('text-change', onChange);
}
};
});Plugins are initialized automatically when a new editor instance is created. If a plugin throws during init, it's caught by handleError and doesn't prevent the editor from working.
Display an interactive configuration guide in the browser console.
Yazman.help();To create a custom inline format:
class Strikethrough extends Yazman.registry.get('pattern/inline') {
constructor (editor, { domNode = null } = {}) {
super(editor, { tagName: 'S', domNode });
}
static getFormat (domNode) {
return { strikethrough: true };
}
}
Strikethrough.tagName = 'S';
Strikethrough.formatName = 'strikethrough';
Strikethrough.toolbar = '<svg>...</svg>';
Yazman.register('format/strikethrough', Strikethrough);Add the dark-mode class to the editor container or any parent element:
document.body.classList.add('dark-mode');Set via the configuration option or HTML attribute:
<div id="editor" data-yazman-placeholder="Type here..."></div>npm install
npm run dev # Dev server at localhost:9004
npm run build # Production build -> dist/
npm run release # Build + copy to docs/assets/The status() method accepts HTML content via innerHTML. Yazman does not sanitize this input — it is your responsibility to ensure all content passed to this method is trusted.