# Markdown Text Editor

### Toolbar, live preview and WYSIWYG on the textarea you already have

[![npm installs][npm_installs]](https://www.npmjs.com/package/markdown-text-editor)
[![Jsdelivr hits][jsdelivr]](https://cdn.jsdelivr.net/npm/markdown-text-editor)
[![Latest Release](https://img.shields.io/npm/v/markdown-text-editor.svg)](https://github.com/nezanuha/markdown-text-editor/releases)
[![License](https://img.shields.io/npm/l/markdown-text-editor.svg)](https://github.com/nezanuha/markdown-text-editor/blob/master/LICENSE)
[![Secured](https://img.shields.io/badge/Security-Passed-green)](https://snyk.io/test/github/nezanuha/markdown-text-editor)
![GitHub Repo stars](https://img.shields.io/github/stars/nezanuha/markdown-text-editor?style=flat)

A lightweight, embeddable JavaScript Markdown editor that transforms a standard HTML `<textarea>` into a full-featured editing experience — without breaking native form submission. Works with any backend (Django, Laravel, PHP, Node.js, Rails) out of the box.

A native-first Markdown editor built on a standard textarea. No data binding, no API — just drop it in and your forms keep working as-is.

**Works standalone.** No Frutjam, no Tailwind, no framework required, the styles are bundled.

![Hybrid mode: headings, bold text and list markers styled live inside the textarea while the Markdown syntax stays visible](https://cdn.frutjam.com/media/plugins/hybrid-mode-markdown-editor.webp)

<sub>Hybrid mode. Text is styled as you type and the Markdown stays where it is, because this is your `<textarea>`, not a copy of it.</sub>

> **No complex APIs. No data binding. No JSON schemas.** Just a `<textarea>` that types Markdown and submits like any normal form field — enhanced with a rich toolbar, live preview, and WYSIWYG hybrid mode.

## ⭐ Why developers choose this over EasyMDE / SimpleMDE

Most JavaScript markdown editors (EasyMDE, SimpleMDE, CodeMirror-based editors) hide your `<textarea>` and edit a copy, writing the value back when the form is submitted. That holds up until something else needs the field: a `required` input the browser cannot focus blocks the submit entirely, and `.value` or `FormData` read before submit returns an empty string, which breaks htmx, Turbo, Livewire, autosave and unsaved-changes guards.

**MarkdownEditor is different.** It sits transparently on top of your existing `<textarea>`:

| Feature | MarkdownEditor | EasyMDE / SimpleMDE |
|---|---|---|
| Native `<textarea>` preserved | ✅ | ❌ Hidden, edited as a copy |
| Works with `required` fields | ✅ | ❌ Browser blocks the submit |
| `.value` correct before submit | ✅ | ❌ Empty until the form submits |
| Serialises with `FormData`, htmx, Turbo | ✅ | ❌ Needs the editor's own API |
| WYSIWYG hybrid mode | ✅ | ❌ |
| Inline event handlers (CSP) | ✅ None | Some |
| RTL support | ✅ Built in | Via CodeMirror's `direction` option |
| Built-in Find & Replace | ✅ | ❌ |
| Swap the markdown parser | ✅ | ❌ |
| Keyboard shortcuts | ✅ | Partial |
| Dark mode / theming | ✅ | Limited |
| Bundle size, gzipped | 51 KB | 107 KB (JS + CSS) |

## 🚀 Quick Start

### NPM (bundlers: Vite, webpack, Rollup, etc.)

```bash
npm install markdown-text-editor
```

```javascript
import MarkdownEditor from 'markdown-text-editor';
new MarkdownEditor('#markdown-editor');
```

### CDN: ES module

```html
<script type="module">
  import MarkdownEditor from 'https://cdn.jsdelivr.net/npm/markdown-text-editor/dist/markdown-text-editor.es.js';
  new MarkdownEditor('#markdown-editor');
</script>
```

### CDN: global script tag (IIFE)

```html
<form method="post" action="/submit">
  <textarea id="markdown-editor" name="content"># Hello World</textarea>
  <button type="submit">Save</button>
</form>

<script src="https://cdn.jsdelivr.net/npm/markdown-text-editor"></script>
<script>
  new MarkdownEditor('#markdown-editor');
</script>
```

**Via CDN**: no import needed, `MarkdownEditor` is available globally.

That's it. Form submission, `.value` access, and all native textarea behaviour work exactly as before.

## ✨ Features

- 🔌 **Native Form Integration** — Works exactly like a standard `<textarea>`. No complex APIs — just use `.value` or the `name` attribute. Compatible with Django, Laravel, PHP, Rails, Node.js, and with React or Vue via a ref and `destroy()` on unmount
- 🔀 **WYSIWYG Hybrid Mode** — Renders bold, italic, headings, and code live as you type while keeping the underlying Markdown. Switch to plain mode for raw syntax editing
- ⚡ **Live Preview** — Full side-by-side Markdown preview with clickable task list checkboxes that sync back to the source instantly
- 🔧 **Bring Your Own Renderer** — Swap marked for markdown-it or any other parser so the preview matches whatever your backend renders. The sanitizer is replaceable too, and DOMPurify still runs by default
- 🏷️ **Variable Dropdown** — Give template authors a menu of readable names that insert placeholders like `{{customer.name}}`. Entries can be grouped, and a sample value can stand in for the placeholder in the preview
- 🖼️ **Advanced Image Upload** — Upload images directly to your server or S3. Avoids heavy Base64 strings for better performance and SEO
- 🔍 **Find & Replace** — Built-in panel (`Ctrl+F` / `Ctrl+H`) with live match counter, next/prev navigation, case-sensitive toggle, and replace all
- ⌨️ **Keyboard Shortcuts** — `Ctrl+B`, `Ctrl+I`, `Ctrl+K`, `Ctrl+Z`, `Ctrl+1`–`Ctrl+3` for headings, and more
- 📝 **Smart List Continuation** — GitHub-style: press `Enter` inside a list and the bullet/number continues automatically
- 🔄 **Undo / Redo** — Full diff-based history with exact cursor restoration. Works with `Ctrl+Z`, `Ctrl+Y`, `Ctrl+Shift+Z`
- ♿ **Accessible by Default** — `role="toolbar"`, `aria-pressed`, `aria-disabled`, `disabled`, screen-reader-friendly SVGs, and correct focus restoration on modal close
- 🛡️ **XSS Safe** — Preview output sanitized via [DOMPurify](https://github.com/cure53/DOMPurify) before rendering, including the output of a custom renderer
- 🛡️ **CSP Compatible** — No inline event handlers. Works with strict Content Security Policy headers
- 🌍 **RTL Support** — Native Right-to-Left support for Arabic, Urdu, Farsi, and other RTL languages
- 🌙 **Dark Mode & Theming** — Inherits `data-theme` from any ancestor element. Built-in light, dark, snowberry, and darkberry themes. Fully customizable via CSS variables
- 🎛️ **Modular Toolbar** — Pick exactly which tools appear and in what order
- 🟦 **TypeScript Ready** — Definitions ship with the package. Options, toolbar entries and variable shapes are all checked, so a mistyped tool name is a compile error rather than a silently missing button
- 📦 **Universal Module Support** — ESM, CommonJS, UMD, and IIFE. Works with Vite, webpack, Rollup, or directly via `<script src>` CDN — no configuration needed
- 🚀 **High Performance** — ~52KB gzipped (245KB minified). Debounced preview, cached layout calculations, conflict-free Tab/Enter handling for large documents

## 🛠 Developer Workflow

### Getting & Setting Content

```javascript
// Get — just like any textarea
const markdown = document.getElementById('markdown-editor').value;

// Set — editor UI updates automatically
document.getElementById('markdown-editor').value = '## Updated content';
```

### React to every change with `onChange`

```javascript
const editor = new MarkdownEditor('#markdown-editor', {
    onChange(value) {
        console.log('Content changed:', value.length, 'characters');
    }
});
```

### Auto-save draft to localStorage

```javascript
const textarea = document.getElementById('markdown-editor');
const saved = localStorage.getItem('draft');
if (saved && !textarea.value) textarea.value = saved;

const editor = new MarkdownEditor('#markdown-editor', {
    onChange(value) {
        localStorage.setItem('draft', value);
    }
});
```

### Tear down in SPAs

```javascript
// Removes editor UI, restores original textarea, cleans up all event listeners
editor.destroy();
```

In React, create the editor in an effect and return `destroy` as the cleanup — that also covers StrictMode running effects twice in development:

```jsx
useEffect(() => {
    const editor = new MarkdownEditor(ref.current, { onChange });
    return () => editor.destroy();
}, []);
```

Use `defaultValue`, not `value`. The editor writes to the textarea directly, so a controlled binding (or Vue's `:value`) would overwrite what the user is typing. Full React and Vue examples are in the [documentation](https://frutjam.com/plugins/markdown-editor).

The same two rules cover Svelte, Angular and anything else: don't bind the value, and call `destroy()` on unmount. In Angular, `onChange` fires outside the zone, so wrap it in `zone.run()` for change detection to notice.

Options are read once when the editor is constructed. Changing them later has no effect — destroy the editor and create a new one instead.

## 📖 Documentation

Full API reference, configuration options, theming guide, and advanced image upload docs:
👉 **[frutjam.com/plugins/markdown-editor](https://frutjam.com/plugins/markdown-editor)**

## WYSIWYG Hybrid Mode vs Plain Mode

### Hybrid Mode — live formatting as you type

![Hybrid mode WYSIWYG styled markdown editor](https://cdn.frutjam.com/media/plugins/hybrid-mode-markdown-editor.webp)

### Plain Mode — raw Markdown syntax

![Plain mode markdown editor](https://cdn.frutjam.com/media/plugins/plain-mode-markdown-editor.webp)

---

## 🤝 Contributing

Contributions are welcome! Bug fixes, feature requests, and improvements — open an issue or submit a pull request.

See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, tests, project layout, and how to add a toolbar tool.

---

## License

[MIT License](LICENSE)

---

## ⭐ Support

If this saves you time, consider giving it a star — it helps others find this project!

[![GitHub stars](https://img.shields.io/github/stars/nezanuha/markdown-text-editor.svg?style=social&label=Star&maxAge=2592000)](https://github.com/nezanuha/markdown-text-editor)

---

[jsdelivr]: https://badgen.net/jsdelivr/hits/npm/markdown-text-editor
[npm_installs]: https://badgen.net/npm/dt/markdown-text-editor
