Skip to content

Repository files navigation

Yazman

MIT License GitHub Live Demo

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.

Features

  • 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-mode CSS class
  • i18n ready: auto-detects from <html lang>, falls back to English

Installation

npm

npm install yazman

Module Import

import Yazman from 'yazman';

Browser (no bundler)

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>

Styles

@use 'pkg:yazman';

The pkg: protocol requires Dart Sass 1.71+ and NodePackageImporter. 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">

Quick Start

<div id="editor"></div>

<script>
  const editor = new Yazman(document.getElementById('editor'), {
    placeholder: 'Start typing...'
  });
</script>

Configuration

const editor = new Yazman(container, options);

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) => {}

Error Handling

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);

Toolbar

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 (Undo/Redo)

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).

Auto-save

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

API

Content Operations

editor.format(start, end, formatObj)

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 });

editor.formatLine(start, end, format)

Apply block-level formatting only (headings, blockquote, preformatted, lists). Does not affect inline formats.

editor.formatLine(0, 50, { preformatted: true });

editor.formatText(start, end, format)

Apply inline formatting only (bold, italic, hyperlink, etc.). Does not affect block formats.

editor.formatText(0, 10, { bold: true, italic: true });

editor.insertNode(nodeObj, index)

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);

editor.deleteContent(start, end)

Delete content in the specified range.

editor.deleteContent(5, 15);

Content Import/Export

editor.getContent(start?, end?)

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);

editor.setContent(contentArray)

Import content from a structured array (same format as getContent).

editor.setContent([
  {
    format: { paragraph: true },
    children: [
      { textContent: 'Imported content', format: {} }
    ]
  }
]);

editor.getText()

Get the plain text content of the editor (no formatting).

const text = editor.getText();

editor.getLength()

Get the total character length of the editor content.

const length = editor.getLength();

Querying Content

editor.paper.getFormat(start, end)

Get the active formats in a range.

const format = editor.paper.getFormat(0, 10);
// Returns: { paragraph: true, bold: true }

editor.paper.getLine(index)

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     → 25

editor.paper.getLines(start, end)

Get all lines in a range.

const lines = editor.paper.getLines(0, 100);

editor.paper.getNode(index)

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         → 5

editor.paper.getNodes(start, end)

Get all inline nodes in a range.

const nodes = editor.paper.getNodes(0, 50);

Selection / Caret

editor.selection.getCaretPosition()

Get the current caret position as [start, end]. When collapsed (no selection), both values are equal.

const [start, end] = editor.selection.getCaretPosition();

editor.selection.setCaretPosition([start, end])

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]);

editor.selection.isCollapsed()

Check if the current selection is collapsed (no text selected).

if (editor.selection.isCollapsed()) {
  // cursor is at a single point
}

Editor State

editor.update()

Manually trigger editor state update. Regenerates the Paper model, updates the toolbar, and repositions the caret. Called automatically after most operations.

editor.update();

editor.isEmpty(insertWarning?, message?)

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');

editor.isSaved

Get or set the saved state of the editor.

if (!editor.isSaved) {
  // editor has unsaved changes
}

editor.isSaved = true;

Focus

editor.focus(preventScroll?)

Focus the editor. By default, prevents scroll jump.

editor.focus();

// Focus and allow scroll to editor
editor.focus(false);

editor.hasFocus()

Check if the editor currently has focus.

if (editor.hasFocus()) {
  // editor is focused
}

editor.scrollIntoView()

Scroll the editor viewport so the current caret position is visible.

editor.scrollIntoView();

Enable / Disable

editor.enable()

Enable editing (set contenteditable to true).

editor.enable();

editor.disable()

Disable editing (set contenteditable to false). The editor becomes read-only.

editor.disable();

Lifecycle

editor.destroy()

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();

UI

editor.status(content, expire?)

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);

editor.dialog.insertModal(domElement, options?)

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 });

editor.dialog.closeModal()

Close the currently open modal.

editor.dialog.closeModal();

Events

editor.on(event, handler)

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

editor.off(event, handler?)

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);

Static Methods

Yazman.register(key, FormatClass)

Register a custom format or module.

Yazman.register('format/strikethrough', StrikethroughClass);

Yazman.addFormatSet(formatSet)

Register a format set (group of mutually exclusive formats).

Yazman.addFormatSet(['headerTwo', 'headerThree', 'preformatted', 'blockquote']);

Yazman.plugin(name, fn)

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.

Yazman.help()

Display an interactive configuration guide in the browser console.

Yazman.help();

Custom Formats

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);

Dark Mode

Add the dark-mode class to the editor container or any parent element:

document.body.classList.add('dark-mode');

Placeholder

Set via the configuration option or HTML attribute:

<div id="editor" data-yazman-placeholder="Type here..."></div>

Development

npm install
npm run dev       # Dev server at localhost:9004
npm run build     # Production build -> dist/
npm run release   # Build + copy to docs/assets/

Security

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.

License

MIT

About

Zero-dependency vanilla JS WYSIWYG rich text editor with plugin architecture, undo/redo, and auto-save.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages